Compound Components logo

Compound Components

Community
dylantarre
compound-components

Builds accessible composable components using Radix/Headless UI patterns. Use when creating Select, Dialog, Tabs, Accordion, Menu, or Dropdown components with proper ARIA, keyboard navigation, and focus management.

Overview

Publisherdylantarre
Repositorydesign-system-skills
Skill namecompound-components
Stars
50
Forks
4
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by dylantarre on GitHub. Read the source before you install it.

Installation

Install the Compound Components AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/dylantarre/design-system-skills/tree/main/skills/patterns/compound-components
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/dylantarre/design-system-skills.git /tmp/design-system-skills
mkdir -p .claude/skills
cp -r /tmp/design-system-skills/skills/patterns/compound-components .claude/skills/compound-components
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Compound Components in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Compound Components on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Compound Components is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Compound Components

Overview

Build accessible, composable components using the compound component pattern. This is how modern component libraries (Radix UI, Headless UI, React Aria) create flexible, accessible primitives that handle complex interactions.

When to Use

  • Building a component library from scratch
  • Creating accessible interactive components (Select, Dialog, Tabs, Accordion)
  • Need flexibility in how components render
  • Want to separate behavior from styling
  • Components with complex state (open/closed, selected, focused)

Implementation Checklist

Copy this checklist when building a compound component:

Compound Component Build:
- [ ] Define component parts and responsibilities (Root, Trigger, Content, Item, etc.)
- [ ] Create Root with context provider (state, refs, generated IDs)
- [ ] Implement child components with ARIA roles and data-state attributes
- [ ] Add keyboard navigation (Arrow keys, Enter/Space, Escape, Tab)
- [ ] Handle focus management (initial focus, focus trap for modals, focus restoration)
- [ ] Test with keyboard-only navigation and screen reader

Quick Reference: Pattern Comparison

PatternExampleFlexibilityComplexity
Monolithic<Select options={[...]} />LowLow
Compound<Select.Root><Select.Trigger /><Select.Content /></Select.Root>HighMedium
HeadlessuseSelect() hookHighestHigh

The Compound Pattern

tsx
// Usage example - caller controls structure
<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>Settings</Dialog.Title>
      <Dialog.Description>Manage preferences</Dialog.Description>
      {/* Custom content */}
      <Dialog.Close>Done</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Architecture

Component Parts

PartPurposeRequired
RootProvides context, manages stateYes
TriggerOpens/activates the componentUsually
ContentMain content areaYes
PortalRenders outside DOM hierarchyFor overlays
OverlayBackdrop behind contentFor modals
TitleAccessible headingFor dialogs
DescriptionAccessible descriptionOptional
CloseCloses the componentUsually
ItemIndividual selectable itemsFor lists

Context Flow

┌─────────────────────────────────────────────────┐
│  Root (Context Provider)                         │
│  ┌───────────────────────────────────────────┐  │
│  │  • open/setOpen state                      │  │
│  │  • triggerRef                              │  │
│  │  • contentRef                              │  │
│  │  • generated IDs                           │  │
│  └───────────────────────────────────────────┘  │
│                      │                           │
│           ┌─────────┴─────────┐                 │
│           ▼                   ▼                 │
│       Trigger              Content              │
│    (reads context)     (reads context)          │
│                              │                  │
│                     ┌────────┴────────┐         │
│                     ▼        ▼        ▼         │
│                  Title   Items    Close         │
└─────────────────────────────────────────────────┘

Implementation: React

Select Component

tsx
// select.tsx
import * as React from 'react';
import { createContext, useContext, useId, useRef, useState } from 'react';

// Types
interface SelectContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
  value: string | undefined;
  setValue: (value: string) => void;
  triggerId: string;
  contentId: string;
  triggerRef: React.RefObject<HTMLButtonElement>;
}

interface SelectRootProps {
  children: React.ReactNode;
  value?: string;
  onValueChange?: (value: string) => void;
  defaultValue?: string;
}

// Context
const SelectContext = createContext<SelectContextValue | null>(null);

function useSelectContext() {
  const context = useContext(SelectContext);
  if (!context) {
    throw new Error('Select components must be used within Select.Root');
  }
  return context;
}

// Root - manages all state
function SelectRoot({
  children,
  value: controlledValue,
  onValueChange,
  defaultValue
}: SelectRootProps) {
  const [open, setOpen] = useState(false);
  const [internalValue, setInternalValue] = useState(defaultValue);
  const triggerRef = useRef<HTMLButtonElement>(null);

  const id = useId();
  const triggerId = `select-trigger-${id}`;
  const contentId = `select-content-${id}`;

  // Controlled vs uncontrolled
  const value = controlledValue ?? internalValue;
  const setValue = (newValue: string) => {
    setInternalValue(newValue);
    onValueChange?.(newValue);
    setOpen(false);
  };

  return (
    <SelectContext.Provider value={{
      open,
      setOpen,
      value,
      setValue,
      triggerId,
      contentId,
      triggerRef,
    }}>
      {children}
    </SelectContext.Provider>
  );
}

// Trigger - opens the select
interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
}

function SelectTrigger({ children, className, ...props }: SelectTriggerProps) {
  const { open, setOpen, triggerId, contentId, triggerRef } = useSelectContext();

  return (
    <button
      ref={triggerRef}
      id={triggerId}
      type="button"
      role="combobox"
      aria-expanded={open}
      aria-haspopup="listbox"
      aria-controls={contentId}
      onClick={() => setOpen(!open)}
      className={className}
      {...props}
    >
      {children}
    </button>
  );
}

// Value - displays current selection
function SelectValue({ placeholder }: { placeholder?: string }) {
  const { value } = useSelectContext();
  return <span>{value || placeholder}</span>;
}

// Content - dropdown container
interface SelectContentProps {
  children: React.ReactNode;
  className?: string;
}

function SelectContent({ children, className }: SelectContentProps) {
  const { open, contentId, triggerId, triggerRef } = useSelectContext();
  const contentRef = useRef<HTMLDivElement>(null);

  // Handle keyboard navigation
  React.useEffect(() => {
    if (!open) return;

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        triggerRef.current?.focus();
      }
    }

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [open, triggerRef]);

  // Handle click outside
  React.useEffect(() => {
    if (!open) return;

    function handleClickOutside(e: MouseEvent) {
      if (
        contentRef.current &&
        !contentRef.current.contains(e.target as Node) &&
        !triggerRef.current?.contains(e.target as Node)
      ) {
        // Close handled by Root
      }
    }

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [open, triggerRef]);

  if (!open) return null;

  return (
    <div
      ref={contentRef}
      id={contentId}
      role="listbox"
      aria-labelledby={triggerId}
      className={className}
    >
      {children}
    </div>
  );
}

// Item - individual option
interface SelectItemProps {
  value: string;
  children: React.ReactNode;
  className?: string;
  disabled?: boolean;
}

function SelectItem({ value, children, className, disabled }: SelectItemProps) {
  const { value: selectedValue, setValue } = useSelectContext();
  const isSelected = value === selectedValue;

  return (
    <div
      role="option"
      aria-selected={isSelected}
      aria-disabled={disabled}
      data-selected={isSelected ? '' : undefined}
      data-disabled={disabled ? '' : undefined}
      onClick={() => !disabled && setValue(value)}
      className={className}
    >
      {children}
    </div>
  );
}

// Export compound component
export const Select = {
  Root: SelectRoot,
  Trigger: SelectTrigger,
  Value: SelectValue,
  Content: SelectContent,
  Item: SelectItem,
};

Usage

tsx
import { Select } from './select';

function Example() {
  const [value, setValue] = useState('');

  return (
    <Select.Root value={value} onValueChange={setValue}>
      <Select.Trigger className="btn">
        <Select.Value placeholder="Select a fruit" />
        <ChevronDownIcon />
      </Select.Trigger>
      <Select.Content className="dropdown">
        <Select.Item value="apple">Apple</Select.Item>
        <Select.Item value="banana">Banana</Select.Item>
        <Select.Item value="cherry" disabled>Cherry (sold out)</Select.Item>
      </Select.Content>
    </Select.Root>
  );
}

Implementation: Dialog/Modal

tsx
// dialog.tsx
import * as React from 'react';
import { createPortal } from 'react-dom';

interface DialogContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
  titleId: string;
  descriptionId: string;
}

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

function useDialogContext() {
  const context = React.useContext(DialogContext);
  if (!context) throw new Error('Dialog components must be within Dialog.Root');
  return context;
}

// Root
function DialogRoot({
  children,
  open: controlledOpen,
  onOpenChange,
  defaultOpen = false
}: {
  children: React.ReactNode;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  defaultOpen?: boolean;
}) {
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
  const open = controlledOpen ?? internalOpen;

  const setOpen = (value: boolean) => {
    setInternalOpen(value);
    onOpenChange?.(value);
  };

  const id = React.useId();

  return (
    <DialogContext.Provider value={{
      open,
      setOpen,
      titleId: `dialog-title-${id}`,
      descriptionId: `dialog-desc-${id}`,
    }}>
      {children}
    </DialogContext.Provider>
  );
}

// Trigger
function DialogTrigger({
  children,
  asChild,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
  const { setOpen } = useDialogContext();

  if (asChild && React.isValidElement(children)) {
    return React.cloneElement(children as React.ReactElement<any>, {
      onClick: () => setOpen(true),
      ...props,
    });
  }

  return (
    <button type="button" onClick={() => setOpen(true)} {...props}>
      {children}
    </button>
  );
}

// Portal
function DialogPortal({ children }: { children: React.ReactNode }) {
  const { open } = useDialogContext();
  if (!open) return null;
  return createPortal(children, document.body);
}

// Overlay
function DialogOverlay({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { setOpen } = useDialogContext();

  return (
    <div
      className={className}
      onClick={() => setOpen(false)}
      aria-hidden="true"
      {...props}
    />
  );
}

// Content
function DialogContent({
  children,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  const { setOpen, titleId, descriptionId } = useDialogContext();
  const contentRef = React.useRef<HTMLDivElement>(null);

  // Trap focus
  React.useEffect(() => {
    const focusableElements = contentRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements?.[0] as HTMLElement;
    firstElement?.focus();
  }, []);

  // Handle escape
  React.useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') setOpen(false);
    }
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [setOpen]);

  // Lock body scroll
  React.useEffect(() => {
    document.body.style.overflow = 'hidden';
    return () => { document.body.style.overflow = ''; };
  }, []);

  return (
    <div
      ref={contentRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      aria-describedby={descriptionId}
      className={className}
      onClick={(e) => e.stopPropagation()}
      {...props}
    >
      {children}
    </div>
  );
}

// Title
function DialogTitle({
  children,
  className,
  ...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
  const { titleId } = useDialogContext();
  return <h2 id={titleId} className={className} {...props}>{children}</h2>;
}

// Description
function DialogDescription({
  children,
  className,
  ...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
  const { descriptionId } = useDialogContext();
  return <p id={descriptionId} className={className} {...props}>{children}</p>;
}

// Close
function DialogClose({
  children,
  asChild,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
  const { setOpen } = useDialogContext();

  if (asChild && React.isValidElement(children)) {
    return React.cloneElement(children as React.ReactElement<any>, {
      onClick: () => setOpen(false),
    });
  }

  return (
    <button type="button" onClick={() => setOpen(false)} {...props}>
      {children}
    </button>
  );
}

export const Dialog = {
  Root: DialogRoot,
  Trigger: DialogTrigger,
  Portal: DialogPortal,
  Overlay: DialogOverlay,
  Content: DialogContent,
  Title: DialogTitle,
  Description: DialogDescription,
  Close: DialogClose,
};

Common Components

Tabs

tsx
// Tab compound structure
<Tabs.Root defaultValue="tab1">
  <Tabs.List>
    <Tabs.Trigger value="tab1">Account</Tabs.Trigger>
    <Tabs.Trigger value="tab2">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="tab1">Account settings...</Tabs.Content>
  <Tabs.Content value="tab2">General settings...</Tabs.Content>
</Tabs.Root>

// Context needs: activeTab, setActiveTab, orientation
// ARIA: tablist, tab, tabpanel, aria-selected, aria-controls

Accordion

tsx
// Accordion compound structure
<Accordion.Root type="single" collapsible>
  <Accordion.Item value="item1">
    <Accordion.Trigger>Section 1</Accordion.Trigger>
    <Accordion.Content>Content 1...</Accordion.Content>
  </Accordion.Item>
  <Accordion.Item value="item2">
    <Accordion.Trigger>Section 2</Accordion.Trigger>
    <Accordion.Content>Content 2...</Accordion.Content>
  </Accordion.Item>
</Accordion.Root>

// Context needs: openItems, setOpenItems, type (single/multiple)
// ARIA: region, button, aria-expanded, aria-controls

Dropdown Menu

tsx
// Menu compound structure
<Menu.Root>
  <Menu.Trigger>Options</Menu.Trigger>
  <Menu.Content>
    <Menu.Item onSelect={() => {}}>Edit</Menu.Item>
    <Menu.Item onSelect={() => {}}>Duplicate</Menu.Item>
    <Menu.Separator />
    <Menu.Item onSelect={() => {}} variant="destructive">Delete</Menu.Item>
  </Menu.Content>
</Menu.Root>

// Context needs: open, setOpen, highlightedIndex
// ARIA: menu, menuitem, aria-haspopup

Styling Patterns

Data Attributes for State

tsx
// Component outputs data attributes
<div
  data-state={open ? 'open' : 'closed'}
  data-selected={isSelected ? '' : undefined}
  data-disabled={disabled ? '' : undefined}
/>
css
/* Style with data attributes */
[data-state="open"] {
  opacity: 1;
}

[data-state="closed"] {
  opacity: 0;
}

[data-selected] {
  background: var(--color-bg-selected);
}

[data-disabled] {
  opacity: 0.5;
  pointer-events: none;
}

With Tailwind

tsx
<Select.Content className="
  absolute mt-1 w-full rounded-md bg-white shadow-lg
  data-[state=open]:animate-in data-[state=closed]:animate-out
  data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
">

CSS Variables for Customization

tsx
// Component defines CSS variable contract
<Dialog.Content style={{
  '--dialog-width': '400px',
  '--dialog-padding': 'var(--spacing-lg)',
}}>
css
.dialog-content {
  width: var(--dialog-width, 500px);
  padding: var(--dialog-padding, 1.5rem);
}

The asChild Pattern

Allows rendering as a different element while keeping behavior:

tsx
interface AsChildProps {
  asChild?: boolean;
  children: React.ReactNode;
}

function SlotButton({ asChild, children, ...props }: AsChildProps & ButtonProps) {
  if (asChild && React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    });
  }

  return <button {...props}>{children}</button>;
}

// Usage
<Dialog.Trigger asChild>
  <a href="#">Open as link</a>
</Dialog.Trigger>

Accessibility Checklist

ComponentARIA RolesKeyboard
Dialogdialog, aria-modal, aria-labelledbyEscape closes, focus trap
Selectcombobox, listbox, optionArrow keys, Enter, Escape
Tabstablist, tab, tabpanelArrow keys, Home/End
Accordionregion, button, aria-expandedEnter/Space toggles
Menumenu, menuitemArrow keys, Enter, Escape

Testing

tsx
// testing-library example
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('Select opens on click and selects item', async () => {
  const onValueChange = jest.fn();

  render(
    <Select.Root onValueChange={onValueChange}>
      <Select.Trigger>Select fruit</Select.Trigger>
      <Select.Content>
        <Select.Item value="apple">Apple</Select.Item>
        <Select.Item value="banana">Banana</Select.Item>
      </Select.Content>
    </Select.Root>
  );

  // Open
  await userEvent.click(screen.getByRole('combobox'));
  expect(screen.getByRole('listbox')).toBeVisible();

  // Select
  await userEvent.click(screen.getByRole('option', { name: 'Apple' }));
  expect(onValueChange).toHaveBeenCalledWith('apple');
});

When NOT to Use Compound Components

SituationBetter Alternative
Simple buttonSingle component
Static contentPlain HTML
One-off componentMonolithic with props
No composition neededProps-based API

Compound components add complexity. Use them when you need the flexibility they provide.

Frequently asked questions

What does the Compound Components AI skill do?

Builds accessible composable components using Radix/Headless UI patterns. Use when creating Select, Dialog, Tabs, Accordion, Menu, or Dropdown components with proper ARIA, keyboard navigation, and focus management.

Why use Compound Components on TypingMind?

Because you install it once and use it with any model. Compound Components is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Compound Components in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/dylantarre/design-system-skills/tree/main/skills/patterns/compound-components. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Compound Components?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Compound Components?

As many as you like. As long as a model supports skills, you can use Compound Components with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Compound Components AI skill free?

Yes. It is published on GitHub by dylantarre under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇