shadimage
react-slot
componentslotsource: ok · hit

React Slot

Vue-style slot composition for React

npx shadcn add "https://react-slot.vercel.app/r/slot.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 8:14:12 PM

components/slot.tsx
'use client';

import * as React from 'react';

const DEFAULT_SLOT = 'DEFAULT';

type Slot = React.ReactNode;

type Slots = Record<string, Slot>;

type SlotName = typeof DEFAULT_SLOT | (string & {});

type SlotContext = { scope: string };

const SlotContext = React.createContext<SlotContext | null>(null);

export function useSlots(caller: string) {
  const context = React.use(SlotContext);

  if (!context) {
    throw new Error(`${caller} must be used inside a Slottable`);
  }

  return context;
}

type Slottable = {
  scope?: string;
  strictDefault?: boolean;
  children: React.ReactNode;
};

export function Slottable({ children, scope = 'Slottable', strictDefault = true }: Slottable) {
  const { slots, template, defaultSlot } = React.useMemo(() => {
    const slots: Slots = {};
    const defaultSlot: React.ReactNode[] = [];

    let template: TemplateElement | undefined;

    React.Children.forEach(children, child => {
      if (isSlot(child)) {
        const { name: slot = DEFAULT_SLOT, children } = child.props;

        if (slot in slots) {
          throw new Error(`Duplicate slot: ${slot} was found`);
        }

        slots[slot] = children;
        return;
      }

      if (isTemplate(child)) {
        if (template) {
          throw new Error(
            'A duplicate Template was found. Only one direct Template child is allowed',
          );
        }

        template = child;
        return;
      }

      defaultSlot.push(child);
    });

    return {
      slots,
      template,
      defaultSlot,
    };
  }, [children]);

  if (template == null) {
    throw new Error(`A single template is required as a direct child of ${scope}`);
  }

  const [tree, foundDefault] = React.useMemo(
    () =>
      traverse(template, {
        placeholder(placeholder) {
          const slot = placeholder.props.name ?? DEFAULT_SLOT;
          const isDefaultSlot = slot === DEFAULT_SLOT;
          const outlet = isDefaultSlot ? defaultSlot : slots[slot];

          return outlet ?? null;
        },
      }),
    [children],
  );

  const renderDefault = !strictDefault && !foundDefault;

  const context: SlotContext = { scope };

  return (
    <SlotContext value={context}>
      {tree}
      {renderDefault && defaultSlot}
    </SlotContext>
  );
}

type Visitors = {
  placeholder: (element: SlotPlaceholderElement) => React.ReactNode;
};

function traverse(template: TemplateElement, visitors: Visitors) {
  const { placeholder } = visitors;

  let foundDefault = false;

  const traverseChildren = (children: React.ReactNode): React.ReactNode[] => {
    return (
      React.Children.map(children, child => {
        if (isSlotPlaceholder(child)) {
          const slot = child.props.name ?? DEFAULT_SLOT;
          const isDefault = slot === DEFAULT_SLOT;
          if (isDefault && !foundDefault) foundDefault = true;

          return placeholder(child);
        }

        if (isParent(child)) {
          const { children, ...props } = child.props;

          return React.cloneElement(child, props, ...traverseChildren(children));
        }

        return child;
      }) ?? []
    );
  };

  return [traverseChildren(template.props.children), foundDefault as boolean] as const;
}

const TemplateContext = React.createContext(false);

type TemplateProps = {
  children: React.ReactNode;
};

export function Template({ children }: TemplateProps) {
  const hasTemplate = React.use(TemplateContext);

  if (hasTemplate) {
    throw new Error('A duplicate Template was found. Only one direct Template child is allowed');
  }

  return <TemplateContext.Provider value={true}>{children}</TemplateContext.Provider>;
}

type SlotPlaceholderProps = {
  name?: SlotName;
  children?: React.ReactNode;
};

export function SlotPlaceholder(_props: SlotPlaceholderProps) {
  return null;
}

type SlotProps = {
  name?: SlotName;
  children: React.ReactNode;
};

export function Slot({ name = DEFAULT_SLOT, children }: SlotProps) {
  const { scope } = useSlots(`Slot: ${name}`);

  React.useEffect(() => {
    throw new Error(`Slot: ${name} must be a direct child of ${scope}`);
  }, [name, scope, children]);

  return null;
}

type SlotElement = React.ReactElement<SlotProps>;

function isSlot(child: React.ReactNode): child is SlotElement {
  const isElement = React.isValidElement<typeof Slot>(child);
  return isElement && child.type === Slot;
}

type TemplateElement = React.ReactElement<TemplateProps>;

function isTemplate(child: React.ReactNode): child is TemplateElement {
  const isElement = React.isValidElement<typeof Template>(child);
  return isElement && child.type === Template;
}

type SlotPlaceholderElement = React.ReactElement<SlotPlaceholderProps>;

function isSlotPlaceholder(child: React.ReactNode): child is SlotPlaceholderElement {
  const isElement = React.isValidElement<typeof SlotPlaceholder>(child);
  return isElement && child.type === SlotPlaceholder;
}

type ParentElement = React.ReactElement<Required<React.PropsWithChildren>>;

function isParent(child: React.ReactNode): child is ParentElement {
  const isElement = React.isValidElement<React.PropsWithChildren>(child);
  return isElement && child.props.children !== undefined;
}

// TODO: later add ref to the Provider after
// TODO: support for Fragment refs' is added