Component patterns
The canonical rules every Playstack component conforms to — file layout, server/client boundary, anatomy, variants, tokens, and accessibility.
1. Purpose & scope
This is the canonical rules document for the Playstack component library. Every component in src/foundations/ui/ and src/foundations/blocks/ conforms to it, and every new component is written against it. It encodes the house style the library already converged on, plus the explicit rules adopted in the registry-readiness pass. Conformance means three things: registry-ready (the file is portable — it drops into any client repo with no project-specific imports and no untokenized literals), consistent (it solves shared problems the same way every other component does), and predictable (an engineer or agent reading one component can predict the shape of every other). It is written for engineers and AI agents who already know React 19 — it states rules, not React fundamentals.
2. File & folder conventions
One folder per component. No exceptions, no shared “misc” files.
- Primitives live in
src/foundations/ui/<name>/. Composed marketing/page sections live insrc/foundations/blocks/<name>/. - Folder names are
kebab-case(color-picker/,app-shell/). Exports arePascalCase(ColorPicker,AppShell). Rationale: matches the filesystem-to-symbol mapping used everywhere else in the repo. - A component folder contains exactly:
<name>.tsx(the source),page.mdx(the docs — required, see §15),examples/(preview files). Nothing else. Rationale: a registry packages a folder; anything extra ships as noise. - The main file is
<name>.tsxmatching the folder name. All sub-components and JSX live in<name>.tsx. Non-component helpers (pure functions, constants, types) may move to sibling files in the same folder (<name>.utils.ts) when the main file exceeds ~400 lines — they still ship as part of the folder. Sub-components never move out. Rationale: a compound component is one unit — it copies as one folder, with the JSX in one file.
3. Server vs Client boundary
Resolution. The patterns audit found
'use client'applied to ~4 components and missing from ~20 others that use hooks. This section makes the rule mechanical.
Every file that uses any of the following has 'use client' as its first line:
- React hooks —
useState,useEffect,useRef,useId,useCallback,useMemo,useLayoutEffect,use(). createContext/ Context consumption.useRef/useImperativeHandle, or reading.currentoff a ref, or browser APIs —document,window,localStorage,IntersectionObserver,setTimeout/setInterval. (Accepting arefprop and passing it to a host element is server-safe — see §8.)- An event handler function the component defines (
const handleClick = …) or attaches to a DOM element it renders. Merely forwarding...props(which may contain handlers) to a host element does not force'use client'. - The
motionlibrary.
Server components — pure presentational components with none of the above — carry no directive. Rationale: the absence of the directive is itself the signal; adding it everywhere is as wrong as omitting it.
Blocks default to Server Component. A block opts into 'use client' only when it owns interactive state (e.g. Carousel). Rationale: marketing sections are mostly static; keep them off the client bundle.
4. Component anatomy
The structural template every component follows.
'use client'; // only if §3 applies
import { useState } from 'react'; // 1. react
import { motion } from 'motion/react'; // 2. motion (if used)
import { ChevronDown } from '@untitledui-pro/icons/solid'; // 3. npm deps
import { cn, cva } from '@/lib/utils/classnames'; // 4. internal utils
import { Slot } from '@/foundations/components/slot/slot';
import { Surface } from '@/foundations/ui/surface/surface'; // 5. sibling components
import type { VariantProps } from 'cva'; // 6. types
// cva — see §5 for when
const widgetStyle = cva({
base: 'inline-flex items-center',
variants: { variant: { default: '…', subtle: '…' }, size: { sm: '…', md: '…' } },
defaultVariants: { variant: 'default', size: 'md' },
});
interface WidgetProps
extends React.ComponentPropsWithRef<'div'>, // extend the host element
VariantProps<typeof widgetStyle> {
asChild?: boolean; // slot/config props
}
function Widget({ className, variant, size, asChild, ...props }: WidgetProps) {
const Comp = asChild ? Slot : 'div';
return <Comp className={cn(widgetStyle({ variant, size }), className)} {...props} />;
}
export { Widget };
export type { WidgetProps };
Rules encoded above:
- Import order: react → motion → npm deps → internal utils → sibling components → types. Rationale: one scan order across the whole library.
- Props type extends the host element —
React.ComponentPropsWithRef<'div'>when forwarding a ref,ComponentPropsWithoutRef<'div'>otherwise,ComponentProps<typeof X>to inherit another component. Rationale: consumers get the full native API for free. - Ref-as-prop, no
forwardRef(see §8). classNameis destructured and merged last viacn()(see §6).- Named exports only.
export defaultis permitted only in*.preview.tsxfiles. Rationale: named exports are greppable and tree-shake predictably. - Prop type interfaces are always exported. Unlike
cvastyle objects (private by default per §5), prop types are public API for composition — they let other components extend or pass through props with full typing. Rationale:HeroconsumesContainerProps['gutter']; a private prop type would break that. - Compound components re-export via
Object.assign(see §9).
5. Variants
Resolution. The audit found three competing approaches —
cva, prop-union + inlinecn()ternaries, and hardcoded class strings.cvais the only approach. ~9 components (tooltip,menu,toaster,tabs,segmented-control,drawer,switch) carry prop-union variants and are non-conforming.
- A component with 2+ variant axes, or any variant axis with 3+ options, uses a
cvastyle object. Prop-union + inlinecn()ternaries are not allowed at that threshold. Rationale: one mechanism for “a prop selects a class set” — the library does not ship two ways to do it. - A single boolean toggle with no other variant axis (e.g.
inline) uses an inlinecn()conditional, notcva. Rationale: acvaobject for one boolean is ceremony. - One axis with two non-boolean options (e.g.
orientation: 'horizontal' | 'vertical') usescn()with a ternary.cvais required only at 2+ axes or 3+ options per single axis. Rationale: the 3+ rule is the floor forcva; below it,cn()is the rule, not a fallback. - The
cvastyle object is named<name>Style(buttonStyle,badgeStyle). - Keep the
cvaobject private (the default). Export it only when another primitive in the library composes it (IconButtonextendsbuttonStyle,OTPInputextendsinputStyle). Do not export “for consistency.” Rationale: an exported style object is public API — widen the surface only when there’s a real composition consumer. The audit foundcontainerStyleexported with zero consumers; that’s the anti-pattern. - Variant names are semantic, not visual —
default/destructive, neverblue/red(see §13).
6. className handling
cnis imported from@/lib/utils/classnames— never fromclsx,tailwind-merge, orcvadirectly. Rationale:cnis the repo’s pre-configuredtailwind-mergewrapper; the raw packages don’t dedupe Tailwind classes the same way.- The consumer’s
classNameis always merged last so it wins conflicts:
className={cn(widgetStyle({ variant }), className)}
Rationale: a consumer passing className expects to override, not be overridden. Last position is the contract.
This is the one pattern the audit found 100% consistent across the library. It stays.
7. asChild / Slot
A component supports asChild if it renders exactly one host element AND that element is a container, trigger, or link — something a consumer might render as a different tag (<a> vs <button>, <section> vs <article>). A component does not support asChild if it renders a native form control (the control is the contract), renders multiple elements, or renders purely decorative markup.
The current library, applying that rule:
- Supports
asChild: structural/layout components (Section,Container,Flex,Grid,Surface,AppShell,Sidebar,PageHeader), triggers (*.Trigger,*.Close), link-like elements (Breadcrumb.Link,Nav.Item,Pagination.Link). - Does not support
asChild: leaf form inputs (Input,Checkbox,Radio,Switch— they are the native element), pure visual components (Skeleton,Divider,Spinner).
const Comp = asChild ? Slot : 'div';
return <Comp {...props} />;
Slot is imported from @/foundations/components/slot/slot — never from Radix. Rationale: asChild lets a consumer compose without wrapper <div>s; leaf inputs and visuals have nothing meaningful to swap, so the prop would be dead weight. The audit flagged Sidebar/PageHeader/AppShell as missing it — structural compounds qualify and should adopt it.
8. Refs
Rule. React 19 ref-as-prop only.
forwardRefis forbidden anywhere in the library. A reviewer flagging “this component doesn’t useforwardRefso it can’t accept a ref” is a false positive — in React 19,refis just a prop andComponentPropsWithRef<'…'>already types it. Do not “fix” components by reintroducingforwardRef.
- A component exposes a
refto its outermost host element. A component that renders no host element of its own (pure composition / context provider) exposes no ref. Compound sub-components each expose their own. - Type the ref via the host-element props —
React.ComponentPropsWithRef<'div'>already includesref. UseComponentPropsWithoutRef<'…'>only when the component deliberately does not forward a ref (rare — pure composition only). - Destructure
reffrom props and pass it through. Do not wrap withforwardRef. Do not declare a separateReact.Ref<…>prop by hand. - Merge an internal ref with a forwarded one using
composeRefsfrom@/foundations/utils/compose-refs/compose-refs.
interface WidgetProps extends React.ComponentPropsWithRef<'div'> {}
function Widget({ ref, ...props }: WidgetProps) {
const internalRef = useRef<HTMLDivElement>(null);
return <div ref={composeRefs(ref, internalRef)} {...props} />;
}
// ❌ Forbidden — forwardRef is legacy and not used in this library.
const Widget = React.forwardRef<HTMLDivElement, WidgetProps>((props, ref) => (
<div ref={ref} {...props} />
));
Rationale: forwardRef is legacy as of React 19; ref-as-prop is one less wrapper and consistent with how every other prop flows. The audit found divider, skeleton, switch, radio, select forwarding no ref — leaf elements still expose theirs so consumers can measure and focus them.
9. Compound components
A multi-part component is assembled with Object.assign and re-exported under the clean name.
function Modal(props: ModalProps) { /* … */ }
function ModalTrigger(props: ModalTriggerProps) { /* … */ }
function ModalContent(props: ModalContentProps) { /* … */ }
const CompoundModal = Object.assign(Modal, {
Trigger: ModalTrigger,
Content: ModalContent,
});
export { CompoundModal as Modal };
Consumers write <Modal.Trigger>, <Modal.Content>. Rationale: one import, discoverable surface, parts that can’t drift from their parent.
State shared between compound parts uses a Context consumed with the React 19 use() hook — never useContext(), and the provider is passed as JSX directly — never .Provider:
const ModalContext = createContext<ModalContextValue | null>(null);
const useModalContext = () => {
const ctx = use(ModalContext);
if (!ctx) throw new Error('Modal.* must be used within <Modal>');
return ctx;
};
// provide:
<ModalContext value={ctx}>{children}</ModalContext>
Context objects are named <Name>Context; consumer hooks use<Name>Context. Sub-components are prefixed with the parent (ModalContent, not Content).
10. Tokens
Every color, spacing value, radius, duration, easing, shadow, and font value comes from the token contract — the @theme blocks in src/foundations/setup/globals-data-attr.css, theme.css, and motion.css. No literals.
- Use the Tailwind utility that maps to the token (
rounded-xl,shadow-sm,bg-background-secondary,text-foreground,duration-(--duration-base)). - A raw value with an existing token equivalent is a bug. The token audit found 15 mechanical mismatches — e.g.
modal/dialog/drawerinlinerounded-[1.25rem]whenrounded-2xlresolves to exactly that, and--radius-2xlwas created intheme.cssfor that purpose. - Tailwind’s default scale is permitted and is the default for layout values. Tailwind’s built-in spacing, type sizes, and container sizes (
py-16,text-xl,max-w-4xl,gap-6) are the default for layout. The family@themecontract adds values not in Tailwind’s defaults — colors, motion, custom radii, family-specific shadows. A component reaches for a family contract token when one exists for that role; otherwise it uses Tailwind’s scale directly. Family contract tokens are not parallel duplicates of the Tailwind scale. Rationale: “no literals” means no arbitrary[…]values and no rawstylenumbers — not “rewrite Tailwind’s scale into custom tokens.” - When a literal has no matching token, decide: system-wide token or one-off? System-wide values are added to the appropriate
@themeblock (and to the motion constants module if motion). One-offs are documented in the component’spage.mdxwith rationale. The default answer is system-wide — one-offs require justification. Rationale: an undocumented one-off is just a hardcoded value with extra steps. - Component-local CSS variables for layout coordination (
--button-height,--surface-pad) are fine; they’re derived from contract tokens, not literals. - JS-side motion values do not use CSS tokens —
motion/Framer configs take JS objects, notvar(--token). They import from the motion constants module (see §11). - Literals are acceptable only in
/demos(the marketing/demo route atsrc/pages/demos/**and its component tree undersrc/components/demos/**). Those files are presentational scaffolding for the docs site — they never ship as part of the library, so an arbitrarygap-[13px]orbg-[#1a1a1a]there is fine. Anywhere else —src/foundations/**, shippable blocks, primitives, examples (*.preview.tsx) — literals are a bug. Rationale: a reviewer or agent finding a literal in/demosshould leave it alone; finding one outside/demosshould treat it as a token regression. Making the boundary explicit prevents both false flags and missed regressions.
See the Token contract for the full token list. The library does not invent values; it composes the contract.
11. Animation
motion (motion/react) is the standard animation library.
Resolution. The token audit found ~10 components (
button,modal,toolbar-menu,toaster,disclosure,segmented-control, …) hand-codingcubic-bezier(0.25, 0.1, 0.25, 1)andduration: 0.2— values bit-identical to--easeand--duration-base. The root cause: amotionconfig cannot reference a CSS variable.
- All
motiontransition/animateconfigs import their durations and easings from the motion constants module — the JS mirror of the CSS motion tokens. No inlinecubic-bezier(...), no inline numeric durations. - CSS-driven animation (Tailwind utilities,
data-motionattributes) uses the CSS motion tokens directly (duration-(--duration-hover),ease-(--ease)). - Reduced motion is the component’s responsibility — one mechanism per case. A component using
motionwraps its motion subtree in<MotionConfig reducedMotion="user">. A component animating via CSSdata-motionattributes needs no handling —motion.cssalready disables it underprefers-reduced-motion. TheuseReducedMotion()hook is used only when motion must be conditionally constructed (not just disabled). Decorative motion is removed; functional motion (a drawer still needs to appear) degrades to a cut.
Rationale: motion that can’t be retuned from one place isn’t a system. The CSS contract and the JS constants module are the same values in two forms — every animation references one of them.
12. Imports & dependencies
A shippable component imports only:
- npm packages (
react,motion,cva,@tanstack/*,date-fns,chroma-js, …). @/lib/utils/*—classnames,compose-refs, and other framework-agnostic utilities.- Sibling foundations components —
@/foundations/ui/*,@/foundations/components/*,@/foundations/hooks/*,@/foundations/utils/*.
Forbidden in shippable components:
@/components/*— docs-site code, not library code.@/lib/preview,@/lib/shiki,@/lib/constants,@/lib/navigation— docs-site infrastructure.astro:*and any Astro-specific import.- Any path that would not exist in a consumer’s repo.
External-library config is exempt from §10’s no-literals rule. Configuration that cannot reference CSS variables — @floating-ui middleware (padding, offset), Recharts geometry, charting margins — stays inline as literals. When the same values are reused across components, they may live in a shared <component>-defaults.ts constant. Rationale: §10 governs design values that can be tokenized; a padding: 8 floating-ui middleware can’t read var(--token), so tokenizing it is impossible, not optional.
In shippable components, icons come from @untitledui-pro/icons/solid only — named exports, no default import, no per-icon subpaths. Docs-site files (src/components/*, examples, previews) may use any icon source. Rationale: a registry component must drop into a foreign repo unchanged; anything that resolves only in this repo breaks on copy. Docs-site code never ships, so it’s unconstrained.
Every cross-foundations import is declared in the component’s page.mdx dependencies: block — pnpm check:deps enforces it.
13. Naming
- Props:
camelCase, spelled out in full —isLoadingnotldng,descriptionnotdesc. No abbreviations. - Variants: semantic, not visual —
default/subtle/destructive, neverblue/gray/red. Rationale: a visual name is a lie the moment the theme changes. - Boolean props: the bare adjective —
disabled,loading,selected,open. Nois/hasprefix. - Data attributes: follow ARIA/HTML conventions —
data-state(open/closed),data-status(transition state),data-orientation,data-selected,data-highlighted,data-disabled. Boolean states aretrueor absent (neverfalse). String states drivedata-[state=open]:selectors. Rationale:data-*attributes are the styling and testing contract; aligning them with ARIA means one vocabulary.
14. Accessibility
The minimum bar — every component meets it before it ships.
- Keyboard: every interaction reachable and operable by keyboard. Composite widgets (
Menu,Listbox,Tabs,SegmentedControl) implement roving tabindex and arrow-key navigation;Escapecloses overlays. - ARIA: correct
role,aria-label/aria-labelledby, and state attributes (aria-expanded,aria-selected,aria-checked,aria-orientation). Native elements are used wherever they exist — don’trole="button"a<div>. - Focus management: overlays (
Modal,Drawer,Dialog,Popover,Menu) trap focus while open and restore it to the trigger on close. - Focus-visible only: focus rings render on
focus-visible, never on mouse click. Use thering-(length:--ring-width) ring-ringtoken pair.
By category: form inputs wire label/description/error via Field and native validity; overlays do focus trap + restore + Escape; navigation uses landmark roles and aria-current; disclosure-like components manage aria-expanded and aria-controls.
15. Documentation
Every component has a page.mdx in its folder.
- Required frontmatter:
title,description. Primitives usefolder: UI(default); blocks usefolder: Blocks. - Required
dependencies:block — every cross-foundations import the source uses, internal entries as paths (/ui/surface), external ashttps://URLs.pnpm check:depsfails the build if it drifts. - Body documents: what the component is, its props and variants, and renders its examples.
- Examples live in
examples/as*.preview.tsxfiles —<name>.preview.tsxfor the default,<name>-<variant>.preview.tsxfor others. Each has anexport default functionand may exportmeta(layout,mode). Available at/preview/<slug>in dev.
Rationale: Foundations is consumed by copy-paste — the page.mdx is the only thing telling a consumer what they’re dragging in. An undocumented component is an unshippable one.
16. What’s explicitly NOT in this doc
Out of scope for this pass — deliberately:
- Testing strategy — Playwright setup and conventions live in the Automated tests guide.
- Performance optimization — memoization, bundle splitting, render profiling.
- Theming engine internals — how the DS config panel, schemes, and
light-dark()machinery work. This doc consumes the token contract; it does not define it. - Changelog discipline — covered in
CLAUDE.md.
This doc is the structural and stylistic contract. Those topics get their own.
17. Forward references
This doc points at two artifacts beyond the core setup CSS. The motion constants module ships with the setup files; the token contract doc is still specified here for when it is built.
Token contract doc
A new guide at src/foundations/guides/token-contract/page.mdx (folder: "Guides", title: "Token contract"). It is generated, not hand-maintained — a script (scripts/extract-tokens.mjs, run in prebuild or as a pnpm task) parses the three @theme blocks in src/foundations/setup/{globals-data-attr,theme,motion}.css and emits a categorized table — Motion / Spacing / Color / Radius / Shadow / Typography — listing each token’s name, resolved value, and the Tailwind utility that maps to it (--radius-2xl → rounded-2xl). The doc has no exports; its job is to be the authoritative list §10 points at, and to settle the Tailwind-default-scale boundary explicitly with a “What is NOT a token” section (Tailwind’s built-in scale — py-16, text-7xl, max-w-4xl — is permitted layout, not a contract token; see §10). Sync: because it’s generated from the CSS it cannot drift, but a pnpm check step regenerates it and fails if the committed file differs — the same mechanism as check:deps. The CSS files remain the single source of truth; the doc is a read-only projection.
Motion constants module
A new file at src/foundations/setup/motion.ts (sits beside the CSS it mirrors; ships with the setup files a consumer copies). It exports the JS mirror of motion.css’s @theme motion tokens as const objects with as const for literal typing:
MOTION_EASE—{ default: [0.25, 0.1, 0.25, 1], spring: [0.15, 1.15, 0.6, 1] }— cubic-bezier control points as 4-tuples, the formmotionaccepts.MOTION_DURATION—{ hover: 0.1, base: 0.2, box: 0.15, sheetIn: 0.3, sheetOut: 0.13 }— in seconds, sincemotionuses seconds while CSS usesms. This conversion is the module’s reason to exist.MOTION_SCALE—{ press: 0.97, enterUp: 0.95, enterDown: 1.1, box: 0.97 }.
Naming: SCREAMING_SNAKE for the objects (module-level constants); camelCase keys mirroring the CSS token suffix (--duration-sheet-in → sheetIn). Sync: the CSS @theme block is the source of truth; this file is hand-written but kept honest by a test (motion.test.ts) that parses motion.css, converts each --duration-* from ms to seconds and each --ease* to a tuple, and asserts the JS values match — drift fails CI. The file header documents that it is a hand-maintained mirror and the CSS is canonical. §11 imports point here.
Previous
Stack
Next
useDetectDevice