# Component API

> Use when designing a component's props and composition: defaults, variants, controlled versus uncontrolled, slots, and how much configuration to expose.

- Skill: `agentsorg/component-api` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add agentsorg/component-api`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentsorg/component-api/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: agentsorg (https://skillmd.com/u/agentsorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentsorg/component-api

---



# Designing a Component's Prop Contract

The default posture: **the zero-prop render is the product.** `<Component />` with nothing passed should be the version you would ship to a customer — right defaults, right motion, right spacing — and every prop that follows exists to turn something off or move it somewhere else, never to assemble the component from parts. Benji Taylor puts it flatly in `/drawesome`: "It's opinionated, and that's the point. The defaults are meant to be the version you ship… Everything below is turning things off or moving them around, not rebuilding it." A component that needs eleven props before it looks right is not a component; it is a template with extra steps. This skill owns the prop contract of any component. `forms` owns what a *field* does — layout, validation timing, autofill tokens — and `ui-states` owns which non-happy-path states a component must be able to render; come here only for the shape of the API that exposes them.

**Read the project's component conventions before designing anything.** Look for an existing primitives layer (Radix, Base UI, Ark, shadcn/ui, Headless UI), an existing variant mechanism (CVA, `tailwind-variants`, vanilla-extract recipes, styled-components), and the React version — on React 19 `ref` is a plain prop and `forwardRef` is dead weight. Match whatever is there. If the codebase spells variants with CVA, your component spells variants with CVA; a second variant system is a permanent tax on everyone who touches the folder afterwards.

## Quick Reference

| Topic | Reference | Open it when |
|---|---|---|
| Working code: context, dual-mode state, `asChild`, refs, slots, invisible edge cases, file layout | `references/implementation.md` | Open it when you have settled the API shape and are about to write the component, and you need the exact code rather than the rule. |

## Core Principles

1. **Ship the defaults, not the knobs.** Most consumers never customize, so the defaults *are* the design. Emil Kowalski's second Sonner principle — good defaults matter more than options — is why a library at 13M+ weekly downloads is usually installed and never configured. Concretely: `variant = "primary"`, `size = "md"`, `type = "button"` (never `"submit"`), and a setup cost of one mounted `<Toaster />` with no hooks and no context to wire. Exception: a headless primitive whose entire purpose is to have no opinion (`Slot`, `VisuallyHidden`) ships behavior with zero visual defaults.

2. **Every prop subtracts or relocates; no prop rebuilds.** Benji's Liveline exposes `2` props, and its maximalist mode (`degen`) is opt-in and off by default — maximalism is an escape hatch, never the shipped state. The test is falsifiable: **if removing a prop would leave the component unable to render, it is not configuration, it is a missing default.** Fix the default instead of documenting the prop. Exception: a component that wraps a spec'd element (`<input>`, `<a>`) inherits that element's whole attribute surface through `{...props}`; those do not count against the budget because you did not design them.

3. **Structure is children, data is a render prop, one optional region is a slot.** A config object (`header={{ title, description }}`) can only ever render what its author anticipated; `<Card><CardHeader><CardTitle>` can render anything. Use `renderItem={(user) => <UserCard user={user} />}` when the consumer supplies the data and you supply the loop, and a slot prop (`header={<h2>Title</h2>}`) for a single optional region. Exception: a component whose structure never varies (`<Badge>`, `<Kbd>`) should take a string — children-as-structure there is ceremony.

4. **Multi-part components share state through context, not prop drilling.** The root provides, the parts consume, so `<Dialog><Dialog.Trigger /><Dialog.Content />` composes in any order the consumer needs without a `triggerText` prop appearing on the root. Exception: two parts with fixed order and no shared state is one component, not a compound — splitting it buys nothing and costs an import.

5. **Controlled and uncontrolled run through one code path.** Consumers who don't own the state get `defaultValue`; consumers who do get `value` + `onChange`; both fire the handler. The switch is exactly `const isControlled = value !== undefined` — never a separate `controlled` boolean prop, which allows the invalid combination. Exception: components whose state lives in the URL or router are controlled-only and must not accept `defaultValue` at all; a second source of truth for a value the address bar already owns is a bug generator.

6. **Name props the way the platform names them.** `disabled`, not `isDisabled`. `open` / `onOpenChange`, not `isOpen` / `handleOpen`. Booleans stated positively, so never `notEnabled` or `isNotClosed`. Consistency across the set matters more than any individual choice: one component with `disabled` and its neighbour with `isDisabled` costs a lookup every single use. Exception: when no platform name exists for the behavior — Radix's `asChild` is the canonical case, and borrowing that established name beats coining a new one.

7. **Forward the ref and spread the rest.** Without `ref` on the DOM node, focus management, tooltip anchoring, and popover positioning break for anyone composing your component; without `{...props}` placed last, `aria-*`, `data-testid`, and event handlers vanish silently. Exception: an attribute you deliberately own — destructure it, apply the default (`type = "button"`), and still let the consumer's explicit value win.

8. **Handle the edge cases invisibly.** Emil's fourth Sonner principle, with its three shipped examples: pause toast timers when `document.hidden` is true; fill the gaps between stacked toasts with pseudo-elements so hover survives the space between them; capture pointer events during a drag so the interaction survives the cursor leaving the element. Users never notice these, and that is exactly right. Related, from the same list: drive interruptible state changes with CSS transitions rather than keyframes, because keyframes restart from zero when a new toast arrives while the last is still animating and transitions retarget from the current value — `motion` owns the curve and the duration, this skill only owns the choice of mechanism. Exception: when invisible handling would confuse a consumer debugging it, expose it as a documented prop whose default is the safe behavior.

9. **The name is part of the API.** Emil chose "Sonner" (French *sonner*, "to ring") over `react-toast` — memorability beats discoverability once a component is a product people recommend by name. Exception: app-private components take boring descriptive names; memorability is a distribution feature, and an internal component is not distributed.

## Smell / Fix

| Smell | Fix |
|---|---|
| `leftIcon`, `rightIcon`, `iconSize`, `iconSpacing` | Children: `<Button><Icon /> Save <Kbd>⌘S</Kbd></Button>` |
| `<Button primary large rounded>` | Named axes: `variant="primary" size="lg" radius="full"` — boolean soup permits invalid combinations |
| 20+ style props (`bg`, `px`, `fontSize`, `radius`) | Variants for the sanctioned set, `className` as the single escape hatch |
| A `controlled` boolean alongside `value` | `value !== undefined` is the switch; delete the prop |
| Root props named `triggerText`, `closeLabel`, `titleText` | Compound parts sharing context |
| Component extracted on its second usage | Wait for the third; two usages is a coincidence, three is a pattern |
| `{...props}` destructured away, or missing entirely | Spread last, always |
| Every prop required in the type | Required props are missing defaults wearing a disguise |

## Output Format

When proposing an API, emit exactly three blocks in this order:

1. **The zero-prop usage** — one snippet showing the component with nothing passed, which is the shipped version.
2. **A props table** — `| Prop | Type | Default | What it turns off or moves |`. Every row must answer that last column; a row that describes what the prop *builds* is a design defect, not a documentation gap.
3. **The escape hatches** — `className`, `asChild`, and any slot, listed last, because they are the exit and not the entrance.

Ship one runnable snippet with the component, not a prose description of it: Emil's sixth principle is that people adopt what they can touch first.

## Checklist

- [ ] `<Component />` with zero props renders the version you would ship
- [ ] Every prop turns something off or moves it; none is required to make it render
- [ ] Structure via children; data via render prop; one optional region via slot
- [ ] Multi-part state shared through context, not props on the root
- [ ] `value` + `onChange` and `defaultValue` both supported via `value !== undefined`
- [ ] Prop names mirror HTML and match the rest of the set; booleans positive
- [ ] Ref forwarded (or plain `ref` prop on React 19), `{...props}` spread last
- [ ] Edge cases handled invisibly; interruptible changes use transitions
- [ ] Variants expressed in the project's existing variant system, not a new one
- [ ] One runnable usage snippet shipped alongside the component

