How a component is built shows up in how it feels. A clean composition API keeps the component flexible without drowning callers in props, and a handful of motion details (press feedback, origin-aware popovers, interruptible transitions) make it feel like the UI is actually listening. This doc covers both: the shape of the API and the polish that makes it responsive.
Compound Components
Reach for compound components when one component owns several related parts that share state. Instead of passing every label and handler down as a prop, expose the parts as sub-components and let them read shared state through context.
// Good - parts share state via context, caller composes freely
<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Delete project?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Content>
</Dialog>
// Bad - every part squeezed into props on one element
<Dialog trigger="Open" title="Delete project?" description="This cannot be undone." closeText="Cancel" />
Use this pattern when:
- Several parts share implicit state (open/closed, selected value).
- The component has slots whose order or presence varies.
- The caller needs to interleave their own markup between the parts.
Skip it for components with a fixed structure and only one to three props. A compound API there is overhead with no payoff. The shared state lives in a context the parent creates, so the sub-components stay in sync without prop drilling.
Props API Design
Pick sensible defaults that cover the common case, then provide escape hatches for the rest.
- Defaults that fit ~80% of uses. A button defaults to
type="button", nottype="submit", so it never submits a form by accident. Defaults should be the safe choice, not the most-features choice. - Variants, not boolean soup. Express mutually exclusive looks as one
variantprop, not a pile of booleans.<Button primary large rounded>cannot encode "what if two are set";<Button variant="primary" size="lg">can. - Consistent naming across the set. If one field uses
disabled, they all usedisabled, neverisDisabledon one andreadonlyon another. Event handlers start withon(onChange,onOpenChange). - Composition over a config object. Let callers nest children rather than describing structure in a
header={{...}}blob. Spread the rest of the props onto the underlying element so arbitrary attributes (aria-label,data-*) pass through, and forward refs on anything that wraps a DOM node.
Name Props in Component Context
The component name already supplies context. Keep parent state descriptive where it shares a large scope, but make the component API terse enough to read naturally.
const [isBillingDialogOpen, setBillingDialogOpen] = useState(false);
<Dialog
isOpen={isBillingDialogOpen}
onClose={() => setBillingDialogOpen(false)}
/>
isDialogOpen and onDialogClose repeat information the <Dialog> call site already communicates. Prefer names that describe the prop's role inside the component.
Derive Behavior Before Adding Booleans
Before adding a boolean, check whether existing props already determine the behavior. A search field does not need showClearButton when its current value and an onClear handler already answer whether the action can appear.
function SearchField({ value, onClear }: SearchFieldProps) {
const canClear = value.length > 0 && onClear !== undefined;
// Render the clear action only when canClear.
}
Use enum props for mutually exclusive variants and derived state for genuine implications. Add a boolean when it represents an independent policy. For example, a dialog's dismissal policy may be separate from whether a controlled parent observes onOpenChange. Any dismissible dialog still needs an accessible close path.
Controlled and uncontrolled
Support both. Track internal state for the uncontrolled case, but defer to the prop when the caller passes a value.
function Toggle({ pressed: controlled, defaultPressed = false, onPressedChange }) {
const [internal, setInternal] = useState(defaultPressed);
const isControlled = controlled !== undefined;
const pressed = isControlled ? controlled : internal;
function handleToggle() {
if (!isControlled) setInternal(!pressed);
onPressedChange?.(!pressed);
}
// ...
}
Polymorphism with asChild
When a component should keep its behavior but render as a different element, expose an asChild prop that merges its props onto the single child instead of rendering its own wrapper. This is how a button becomes a link without duplicating styles.
import { Slot } from "@radix-ui/react-slot";
function Button({ asChild, ...props }) {
const Comp = asChild ? Slot : "button";
return <Comp {...props} />;
}
// Renders an <a> styled as the button
<Button asChild>
<a href="/pricing">See pricing</a>
</Button>
Build Complex Examples as Standalone Components
An embedded example is easier to understand and reuse when it can run without the documentation shell around it. Build complex demonstrations as focused components with their own route, then embed the same implementation where the explanation needs it.
- Keep the example's state and coordinate system local to the example. It should not depend on the parent page's scroll position, navigation state, or layout measurements.
- Back the embedded and standalone views with the same component. Two implementations will drift and make the copied version less trustworthy.
- Use the standalone route to test full-screen behavior, keyboard focus, resizing, touch input, and interruption without documentation chrome competing for input.
- Expose only the source and configuration needed to reproduce the behavior. A copyable example should not pull in unrelated platform code.
- Provide a reset action for stateful or timing-sensitive examples so the same behavior can be examined repeatedly.
Use an iframe only when a clean document boundary matters. Give it a descriptive title and responsive dimensions. Start with a restrictive sandbox, add only the capabilities the demo needs, and use allow for any required browser APIs. Keep a working standalone route, and do not use the boundary to avoid integrating ordinary production UI.
Keep High-Frequency Values Out of the Render Loop
Pointer position, scroll offset, and drag translation can update dozens of times per second. Do not put those raw values in React state unless other rendered UI genuinely depends on every update. Write to the owned element through a ref, or use a Motion value that updates outside React reconciliation.
function CursorFollower() {
const markerRef = useRef<HTMLDivElement>(null);
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
if (!markerRef.current) return;
const bounds = event.currentTarget.getBoundingClientRect();
const x = event.clientX - bounds.left;
const y = event.clientY - bounds.top;
markerRef.current.style.transform =
`translate3d(${x}px, ${y}px, 0)`;
}
return (
<div onPointerMove={onPointerMove} style={{ position: "relative" }}>
<div
ref={markerRef}
aria-hidden
style={{ position: "absolute", inset: "0 auto auto 0" }}
/>
</div>
);
}
Keep semantic state in React. The selected item, announced value, and saved result still belong in declarative state. The optimization applies to transient presentation values that would otherwise rerender a large subtree on every pointer or scroll event.
Buttons Must Feel Responsive
A pressable element should react the instant it is pressed. Nudge it down with a small scale on :active, kept between 0.95 and 0.98 so it reads as a press, not a collapse.
.button {
transition: transform 160ms ease-out;
}
.button:active {
transform: scale(0.97);
}
Never animate an entering element up from scale(0). Nothing in the physical world pops out of nothing, so a zero-scale entrance looks unnatural. Start from scale(0.9) or higher paired with opacity, so the element already has a shape before it settles in.
/* Bad - materializes from nowhere */
.entering { transform: scale(0); }
/* Good - already has a shape, then settles */
.entering { transform: scale(0.95); opacity: 0; }
Origin-Aware Popovers and Menus
A popover, dropdown, or menu should scale out from the trigger that opened it, not from its own center. The default transform-origin: center is wrong for almost every anchored surface. Set the origin from the value the positioning library exposes.
/* Radix UI */
.popover {
transform-origin: var(--radix-popover-content-transform-origin);
}
/* Base UI */
.menu {
transform-origin: var(--transform-origin);
}
Modals are the exception: they are not tied to a trigger, they sit centered in the viewport, so they keep transform-origin: center. No single user will name the origin detail, but across an interface these small choices add up to UI that feels considered.
Tooltips Skip the Delay on Quick Hovers
A tooltip should wait a beat before its first appearance so a passing cursor does not trigger a flicker of popups. Once one tooltip is open, though, moving to an adjacent trigger should open the next one instantly with no animation. It feels quick without giving up the safety of the initial delay.
.tooltip {
transition: transform 125ms ease-out, opacity 125ms ease-out;
transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
opacity: 0;
transform: scale(0.97);
}
/* Subsequent tooltip in a quick sequence: open with no delay or motion */
.tooltip[data-instant] {
transition-duration: 0ms;
}
Prefer CSS Transitions Over Keyframes
For anything a user can trigger rapidly (stacking toasts, flipping a toggle), use CSS transitions, not keyframe animations. A transition can be interrupted and retargeted partway through, so a state that changes again mid-flight glides to the new target. A keyframe animation restarts from frame zero, which reads as a stutter under fast input.
/* Interruptible - retargets smoothly mid-animation */
.toast {
transition: transform 400ms ease;
}
/* Restarts from zero on each trigger - avoid for dynamic UI */
@keyframes slideIn {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
Mask Imperfect Transitions With Blur
When a crossfade between two states still looks off after trying different easings and durations, add a small filter: blur(2px) during the transition. The reason it helps: in a plain crossfade you briefly see two distinct objects overlapping, the old state and the new one, which looks wrong. A touch of blur blends them so the eye reads one smooth change instead of two things swapping. Keep blur under 20px; heavy blur is costly to render, especially in Safari.
.button-content {
transition: filter 200ms ease, opacity 200ms ease;
}
.button-content.transitioning {
filter: blur(2px);
opacity: 0.7;
}
Animate Enter States With @starting-style
@starting-style supplies the values an element transitions from on its first style update. It reaches the cases an ordinary transition cannot: elements newly added to the DOM, popovers and modal dialogs promoted to the top layer, and elements changing to or from display: none. In all of those the browser skips the transition by default and the element simply appears.
It replaces the older React habit of flipping a mounted flag in a useEffect after the first render purely to drive an entrance. It is worth using, but it comes with constraints that produce a silent no-op rather than an error, so ship it knowing all three.
Prefer the nested form. Nesting the block inside the rule it applies to puts it after that rule's declarations by construction, which sidesteps the ordering trap below.
.toast {
opacity: 1;
translate: 0;
transition: opacity 400ms ease, translate 400ms ease;
@starting-style {
opacity: 0;
translate: 0 100%;
}
}
It only affects transitions. A keyframe animation already runs on first paint, so @starting-style does nothing for one. If the entrance is authored with @keyframes, this is not the tool.
A standalone block has the same specificity as the rule it targets, so source order decides the winner. Put the standalone @starting-style after the rule declaring the settled state. Before it, the settled values override the starting values and the element appears with no transition. This is the single most common reason "@starting-style does nothing for me".
/* Wrong: the settled rule wins, no entrance runs */
@starting-style {
.toast { opacity: 0; }
}
.toast { opacity: 1; transition: opacity 400ms ease; }
/* Right: starting values come after the rule they start */
.toast { opacity: 1; transition: opacity 400ms ease; }
@starting-style {
.toast { opacity: 0; }
}
Toggling display needs transition-behavior: allow-discrete. An element going from display: none to shown does not animate on @starting-style alone. display has to be in the transition list with discrete transitions allowed, and a top-layer element needs overlay there too so it stays promoted for the whole exit rather than vanishing at the first frame.
/* Closed state, and the state the exit transition lands on */
.dialog {
display: none;
opacity: 0;
transition:
opacity 300ms ease,
display 300ms allow-discrete,
overlay 300ms allow-discrete;
}
/* Open state */
.dialog[open] {
display: block;
opacity: 1;
@starting-style {
opacity: 0;
}
}
Both ends of the toggle have to be declared, or there is nothing to transition between. The closed rule owns display: none and the transition list, so it drives the exit as well as the entrance. Leave the closed state out and display never changes, allow-discrete has nothing to act on, and the element simply appears and disappears.
display is special-cased among discrete properties: it flips to the visible value at the start of the transition and back to none only at the end. That is what gives the fade 300ms to run in each direction rather than the element blinking out on the first frame.
Fall back to a data-mounted attribute set after mount where the feature is unavailable.
Reduced Motion For Entrances
Having no transition is the whole fix. With no transition to run, the browser paints the settled state directly, so the element still arrives, it just does not fly in.
Declare the transition inside @media (prefers-reduced-motion: no-preference) rather than removing it inside a reduce block. The feature has exactly two values, so a browser that cannot evaluate the query matches neither, and the gated form means that browser gets the settled state instead of the full entrance. accessibility.md has the argument in full.
.dialog {
display: none;
opacity: 0;
}
.dialog[open] {
display: block;
opacity: 1;
@starting-style {
opacity: 0;
}
}
@media (prefers-reduced-motion: no-preference) {
.dialog {
transition:
opacity 300ms ease,
display 300ms allow-discrete,
overlay 300ms allow-discrete;
}
}
Only the transition moves into the query. Both states stay in the ordinary rules, so a reduced-motion user still gets a dialog that opens and closes, just with no fade across the change.
The @starting-style block can stay where it is. Its values only apply to a transition that is about to run, so without the transition they are inert, and the open dialog paints at opacity: 1.
The values inside @starting-style are never a resting state, so they cannot strand content. Keep it that way: an entrance must be non-essential, and the toast has to be readable whether or not the transition ran. The same rule applies to the press, popover and tooltip motion above. Cut the movement under the preference, never the element.
Checklist
- Use compound components when parts share state; share it via context, not prop drilling
- Skip compound APIs for fixed-structure components with one to three props
- Sensible defaults (safe over feature-rich), variants instead of boolean soup, consistent prop names
- Prop names rely on component context; behavior is derived before a new boolean is introduced
- Support both controlled and uncontrolled state where the component holds state
- Offer asChild / Slot for polymorphism; spread remaining props and forward refs
- Complex examples share one isolated implementation across embedded and standalone views, with focused source and a reset action
- High-frequency presentation values update outside React state; semantic state remains declarative
- Pressable elements scale to ~0.97 on :active, never enter from scale(0)
- Popovers and menus scale from the trigger via transform-origin; modals stay centered
- Tooltips delay the first open but open instantly on subsequent quick hovers
- Use CSS transitions (not keyframes) for anything triggered rapidly so motion stays interruptible
- Reach for a small blur (under 20px) to mask an imperfect crossfade
- Animate entrances with @starting-style, nested inside the rule it starts; fall back to a mounted attribute when needed
- A standalone @starting-style block comes after the rule declaring the settled state, or it is silently overridden
- @starting-style is paired with a transition, never with @keyframes
- Elements toggling
displaylistdisplay(andoverlayin the top layer) withallow-discrete - The transition is declared inside
@media (prefers-reduced-motion: no-preference), and the element still appears in its settled state without it