Slot

Create polymorphic components with asChild composition for flexible element rendering.

Slot

Slot, createSlot, and isValidElement are utilities for building polymorphic components that support composition via the asChild pattern. This allows components to render different HTML elements while preserving styling and behavior.

The asChild pattern enables composing styled components with custom elements without introducing extra DOM nodes.


Exports

Prop

Type


The asChild pattern

The asChild pattern allows a component to render a different element while preserving the styling and behavior of the original component:

// Button normally renders a <button>
<Button>Click me</Button>          //<button>Click me</button>

// With asChild, render as an <a> instead
<Button asChild>
  <a href="/page">Click me</a>
</Button>                          //<a href="/page" class="button-styles">Click me</a>

The button's classes and behavior are merged onto the <a> element without an extra <button> wrapper.


How Slot works

When asChild={true}, the component uses Slot instead of rendering a normal element. Slot merges all props (className, event handlers, etc.) onto its child:

import { Slot } from '@loke/design-system/slot';

export const Button = forwardRef<
  HTMLButtonElement,
  ButtonProps & { asChild?: boolean }
>(({ asChild = false, className, ...props }, ref) => {
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      ref={ref}
      className={cn('px-4 py-2 rounded', className)}
      {...props}
    />
  );
});

When asChild={true}, Slot extracts the child element and applies the merged props to it.


Usage

Basic polymorphic component

import { Slot, createSlot } from '@loke/design-system/slot';
import { forwardRef, type ButtonHTMLAttributes } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, className, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp
        ref={ref}
        className={cn('px-4 py-2 rounded bg-primary text-white', className)}
        {...props}
      />
    );
  }
);

Using createSlot for display names

import { createSlot } from '@loke/design-system/slot';

const ButtonSlot = createSlot('Button');

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, ...props }, ref) => {
    const Comp = asChild ? ButtonSlot : 'button';
    return <Comp ref={ref} {...props} />;
  }
);

Rendering as different elements

// Render button as an anchor
<Button asChild>
  <a href="/home">Go Home</a>
</Button>

// Render button as a link from Next.js
<Button asChild>
  <Link href="/home">Go Home</Link>
</Button>

// Render as a custom component
<Button asChild>
  <NavLink to="/home">Go Home</NavLink>
</Button>

Validation with isValidElement

Use isValidElement to safely check if a value is a valid React element before operating on it:

import { isValidElement } from '@loke/design-system/slot';

function MyComponent({ children }) {
  if (!isValidElement(children)) {
    return <div>Invalid child</div>;
  }

  return <>{children}</>;
}

Props merging behavior

When using Slot, props are intelligently merged:

  • className: Merged with cn() so Tailwind classes don't conflict
  • style: Merged with style from child taking precedence
  • Event handlers: Composed so both parent and child handlers fire
  • data- attributes:* Merged, with child values taking precedence
  • ref: Forwarded to the underlying element

Example:

<Slot className="px-4" onClick={handleClick}>
  <button className="py-2" onClick={handleChildClick}>
    Click
  </button>
</Slot>

// Results in:
// <button className="px-4 py-2" onClick={composeHandlers(handleClick, handleChildClick)}>
//   Click
// </button>

Best practices

When to use asChild:

  • Rendering components as different elements (links, custom components)
  • Avoiding extra DOM nodes when styling other elements
  • Building flexible, composable component APIs

Props to forward:

  • Always forward ref when using asChild
  • Document the asChild prop in your component's API
  • Ensure merging behavior is intuitive (e.g., className merging)

Common pattern:

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, variant, size, className, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp
        ref={ref}
        className={cn(
          buttonVariants({ variant, size }),
          className
        )}
        {...props}
      />
    );
  }
);

Button.displayName = 'Button';

Examples

import { Button } from '@loke/design-system/button';
import Link from 'next/link';

// As anchor
<Button asChild variant="outline">
  <a href="/docs">Documentation</a>
</Button>

// As Next.js Link
<Button asChild>
  <Link href="/dashboard">Dashboard</Link>
</Button>

// As custom router component
<Button asChild variant="ghost">
  <RouterLink to="/settings">Settings</RouterLink>
</Button>

// As form submit button (when button isn't semantic)
<Button asChild type="submit">
  <div role="button">Submit</div>
</Button>

Technical details

Slot works by:

  1. Checking if asChild is true
  2. Extracting the single child React element
  3. Merging parent props onto the child's props
  4. Returning the merged child element

The implementation ensures type safety and proper ref handling across different element types.


Import

import { Slot, createSlot, isValidElement } from '@loke/design-system/slot';

Or from the ui package directly:

import { Slot, createSlot, isValidElement } from '@loke/ui/slot';