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
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.Every prop subtracts or relocates; no prop rebuilds. Benji's Liveline exposes
2props, 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.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. UserenderItem={(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.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 atriggerTextprop 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.Controlled and uncontrolled run through one code path. Consumers who don't own the state get
defaultValue; consumers who do getvalue+onChange; both fire the handler. The switch is exactlyconst isControlled = value !== undefined— never a separatecontrolledboolean prop, which allows the invalid combination. Exception: components whose state lives in the URL or router are controlled-only and must not acceptdefaultValueat all; a second source of truth for a value the address bar already owns is a bug generator.Name props the way the platform names them.
disabled, notisDisabled.open/onOpenChange, notisOpen/handleOpen. Booleans stated positively, so nevernotEnabledorisNotClosed. Consistency across the set matters more than any individual choice: one component withdisabledand its neighbour withisDisabledcosts a lookup every single use. Exception: when no platform name exists for the behavior — Radix'sasChildis the canonical case, and borrowing that established name beats coining a new one.Forward the ref and spread the rest. Without
refon 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.Handle the edge cases invisibly. Emil's fourth Sonner principle, with its three shipped examples: pause toast timers when
document.hiddenis 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 —motionowns 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.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:
- The zero-prop usage — one snippet showing the component with nothing passed, which is the shipped version.
- 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. - 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+onChangeanddefaultValueboth supported viavalue !== undefined - Prop names mirror HTML and match the rest of the set; booleans positive
- Ref forwarded (or plain
refprop 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