Skip to main content
The Slot component enables advanced component composition by merging props from a parent into its child element, allowing for flexible and reusable component patterns.

When to Use

  • Build polymorphic components with asChild patterns
  • Merge event handlers and props across component boundaries
  • Create flexible wrapper components that don’t add DOM nodes
  • Implement headless UI patterns
  • Compose ref forwarding across nested components

Basic Usage

import { Slot } from "@zayne-labs/ui-react/common/slot";

function Button({ asChild, ...props }) {
  const Component = asChild ? Slot.Root : 'button';

  return (
    <Component {...props} className="btn">
      {props.children}
    </Component>
  );
}

// Usage
<Button>Click me</Button>
// Renders: <button class="btn">Click me</button>

<Button asChild>
  <a href="/home">Go Home</a>
</Button>
// Renders: <a href="/home" class="btn">Go Home</a>

Component API

Slot.Root

Merges all props into the first valid React element child.
children
React.ReactNode
required
Single React element to merge props into. Multiple children or non-elements will return null
ref
React.Ref<HTMLElement>
Ref to forward. Will be composed with child’s ref if it exists
...props
HTMLAttributes
Any props to merge into the child element (className, onClick, style, etc.)

Slot.Slottable

Marks content that should be preserved when using asChild pattern.
children
React.ReactNode
required
Content to preserve from the child element when slotting

Examples

Polymorphic Button Component

import { Slot } from "@zayne-labs/ui-react/common/slot";
import { forwardRef } from "react";

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

    return (
      <Component
        ref={ref}
        className={`btn ${className ?? ''}`}
        {...props}
      />
    );
  }
);

// Use as button
<Button onClick={handleClick}>Submit</Button>

// Use as link
<Button asChild>
  <a href="/submit">Submit</a>
</Button>

// Use as Next.js Link
<Button asChild>
  <Link href="/submit">Submit</Link>
</Button>

Merging Event Handlers

import { Slot } from "@zayne-labs/ui-react/common/slot";

function TrackedButton({ asChild, onClick, ...props }) {
  const Component = asChild ? Slot.Root : 'button';

  const handleClick = (e) => {
    // Analytics tracking
    analytics.track('button_clicked');

    // Call original onClick
    onClick?.(e);
  };

  return <Component onClick={handleClick} {...props} />;
}

// Both onClick handlers will be called
<TrackedButton asChild>
  <button onClick={(e) => console.log('Child clicked')}>Click</button>
</TrackedButton>

Complex Icon Button

import { Slot } from "@zayne-labs/ui-react/common/slot";

function IconButton({ asChild, icon, children, position = 'left' }) {
  const Component = asChild ? Slot.Root : 'button';

  return (
    <Component className="icon-button">
      {position === 'left' && <span className="icon">{icon}</span>}
      <Slot.Slottable>{children}</Slot.Slottable>
      {position === 'right' && <span className="icon">{icon}</span>}
    </Component>
  );
}

// Usage
<IconButton icon={<SaveIcon />} asChild>
  <button onClick={handleSave}>Save Changes</button>
</IconButton>

Tooltip Trigger

import { Slot } from "@zayne-labs/ui-react/common/slot";
import { useState } from "react";

function TooltipTrigger({ asChild, tooltip, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const Component = asChild ? Slot.Root : 'div';

  return (
    <>
      <Component
        onMouseEnter={() => setIsOpen(true)}
        onMouseLeave={() => setIsOpen(false)}
        aria-describedby="tooltip"
      >
        {children}
      </Component>
      {isOpen && (
        <div id="tooltip" role="tooltip" className="tooltip">
          {tooltip}
        </div>
      )}
    </>
  );
}

// Usage - events merge with button's events
<TooltipTrigger tooltip="Delete this item" asChild>
  <button onClick={handleDelete}>Delete</button>
</TooltipTrigger>

Ref Forwarding

import { Slot } from "@zayne-labs/ui-react/common/slot";
import { useRef, forwardRef } from "react";

const AutoFocusWrapper = forwardRef(({ asChild, ...props }, ref) => {
  const Component = asChild ? Slot.Root : 'div';
  const internalRef = useRef();

  useEffect(() => {
    internalRef.current?.focus();
  }, []);

  return <Component ref={ref ?? internalRef} {...props} />;
});

// Refs are properly composed
<AutoFocusWrapper asChild>
  <input ref={myInputRef} />
</AutoFocusWrapper>

Dialog Trigger Pattern

import { Slot } from "@zayne-labs/ui-react/common/slot";

function DialogTrigger({ asChild, children, onOpenChange }) {
  const Component = asChild ? Slot.Root : 'button';

  return (
    <Component
      onClick={() => onOpenChange(true)}
      aria-haspopup="dialog"
    >
      {children}
    </Component>
  );
}

function Dialog({ trigger, children }) {
  const [open, setOpen] = useState(false);

  return (
    <>
      <DialogTrigger asChild onOpenChange={setOpen}>
        {trigger}
      </DialogTrigger>
      {open && <DialogContent>{children}</DialogContent>}
    </>
  );
}

// Usage
<Dialog trigger={<button>Open Settings</button>}>
  <SettingsPanel />
</Dialog>

Comparison to Native Patterns

Without Slot (Wrapper Element)

function Button({ className, ...props }) {
  return (
    <button className={`btn ${className}`} {...props} />
  );
}

// Must always render as button
<Button>Click me</Button>
// Cannot render as link without creating separate component

With Slot (Flexible Composition)

import { Slot } from "@zayne-labs/ui-react/common/slot";

function Button({ asChild, className, ...props }) {
  const Component = asChild ? Slot.Root : 'button';
  return <Component className={`btn ${className}`} {...props} />;
}

// Renders as button
<Button>Click me</Button>

// Renders as link with same styles
<Button asChild>
  <a href="/home">Go Home</a>
</Button>

Common Use Cases

Design System Components

import { Slot } from "@zayne-labs/ui-react/common/slot";

function Card({ asChild, variant = 'default', ...props }) {
  const Component = asChild ? Slot.Root : 'div';

  return (
    <Component
      className={`card card-${variant}`}
      {...props}
    />
  );
}

// Regular div
<Card>Content</Card>

// As article
<Card asChild>
  <article>Article content</article>
</Card>

// As section
<Card asChild>
  <section>Section content</section>
</Card>
import { Slot } from "@zayne-labs/ui-react/common/slot";

function NavItem({ asChild, active, ...props }) {
  const Component = asChild ? Slot.Root : 'a';

  return (
    <Component
      className={`nav-item ${active ? 'active' : ''}`}
      {...props}
    />
  );
}

// Regular link
<NavItem href="/home" active>Home</NavItem>

// With React Router
<NavItem asChild active>
  <NavLink to="/home">Home</NavLink>
</NavItem>

// With Next.js
<NavItem asChild active>
  <Link href="/home">Home</Link>
</NavItem>

Accessible Disclosure

import { Slot } from "@zayne-labs/ui-react/common/slot";

function DisclosureTrigger({ asChild, expanded, ...props }) {
  const Component = asChild ? Slot.Root : 'button';

  return (
    <Component
      aria-expanded={expanded}
      aria-controls="disclosure-content"
      {...props}
    />
  );
}

<DisclosureTrigger asChild expanded={isExpanded}>
  <button onClick={toggle}>Show Details</button>
</DisclosureTrigger>
The Slot component automatically merges refs using composeRefs and props using mergeProps, ensuring event handlers and other props are properly combined.
Slot.Root requires exactly one valid React element child. Multiple children or text nodes will cause it to return null.

Implementation Details

  • Props are merged using mergeProps utility (className concatenation, event handler composition)
  • Refs are composed using composeRefs to support multiple ref assignments
  • Slot.Slottable marks content to preserve from the child when using asChild
  • Fragments are supported but their ref will not be forwarded

Build docs developers (and LLMs) love