build-primitive
Build foundational UI primitives from scratch with strong ARIA, keyboard, focus, and state handling.
Install
npx skills add https://github.com/PrototyperAI/prototyper-ui --skill build-primitive- tools
- [Read, Glob, Grep, Write, Edit, Bash]
Prototyper UI — Building Primitive Components
How to build a 10/10 component from scratch when no Base UI primitive exists. ARIA contracts first, then keyboard navigation, then state management, then styling hooks, then form integration.
When does this apply? When you need a component that Base UI does not provide — Breadcrumb, Avatar, Badge, Card, Skeleton, Rating, Pagination, Stepper, Tag Input, File Upload, Calendar, Data Table, Carousel, Command Palette, Resizable Panels, Tree View, etc.
Philosophy: A primitive-quality component is invisible infrastructure. It handles every edge case so consumers never have to. Every interactive element must be keyboard-operable, every state must be announced to assistive technology, every animation must respect prefers-reduced-motion, and every form control must participate in native form submission.
1. Decision Gate
Before building from scratch, verify that no existing solution handles the hard parts.
Can a third-party library handle the hard parts?
Before building complex interactivity from scratch, check if a headless library provides the state machine:
If a headless library exists, wrap it with Prototyper UI styling patterns (data-slot, cn(), tokens, animation) rather than reimplementing the state machine.
2. Presentational Template
For components with no interactive behavior — just semantic HTML, styling, and slots.
Used by: Avatar, Badge, Card, Skeleton, Separator, AspectRatio
Presentational Rules
- Use semantic HTML elements (
<nav>,<ol>,<figure>,<time>,<hr>) — never a<div>when a semantic element exists - No ARIA roles needed when HTML semantics are sufficient
- No keyboard handling needed (unless it contains interactive children)
- Props type:
React.ComponentProps<"element">— not Base UI types - Still needs:
data-slot,cn()merge, CVA if variants exist - Still needs:
"use client", named exports, no forwardRef
3. Interactive Template
For components with keyboard interaction and ARIA state — but not form submission.
Used by: Breadcrumb, Pagination, Rating, Stepper, Disclosure, Tree View
Interactive Patterns Explained
This template demonstrates all the core patterns you need for interactive components:
1. Controlled/Uncontrolled dual-mode (the useControllableState hook)
2. ARIA role + states (role="slider", aria-valuenow, aria-valuemin, aria-valuemax)
3. Keyboard navigation (onKeyDown with arrow keys, Home/End)
4. Focus management (tabIndex, focus-visible:focus-ring)
5. Data attributes for styling (data-filled on items)
6. Disabled/ReadOnly states (aria-disabled, conditional keyboard handling)
4. Form Control Template
For components that participate in form submission — need hidden inputs and Field compatibility.
Used by: Tag Input, File Upload, Color Picker, Custom Select
Form Integration Patterns
Hidden inputs for form submission:
Validation with hidden inputs:
Field wrapper compatibility:
The component must work when wrapped by <Field>:
For this to work:
- The visible input must connect to Field via
id(Field auto-generates this) - Error states should respond to
data-invalidfrom the parent Field - Add
data-invalid:invalid-field-ringto the container if it has a border
5. Composite Template
For complex components with multiple interactive regions, roving focus, and ARIA composite patterns.
Used by: Calendar, Data Table, Tree View, Command Palette
This template shows the roving tabindex pattern — the foundation for any composite widget with arrow-key navigation.
6. ARIA Role Selection
Which role for which component?
Essential ARIA State Attributes
Naming Rules
Never include the role name in the label. "Close" not "Close button." "Navigation" not "Navigation nav."
7. Keyboard Navigation Patterns
Pattern A: Single Focusable Element
For components with one interactive surface (Button, Toggle, Rating, Disclosure).
Pattern B: Roving Tabindex (Composite Widget)
For components with multiple focusable items where only ONE is in the tab order at a time (Toolbar, Tabs, Listbox, Radio Group, Pagination).
Implementation: See the useRovingFocus hook in Section 5.
Rules:
- Active item gets
tabIndex={0}, all others gettabIndex={-1} - Arrow keys move focus AND update
tabIndexvalues - On initial focus, land on the selected item (or first item if none selected)
- Loop: wrap around when reaching the end (optional, configurable)
Pattern C: Active Descendant (Combobox-style)
For components where focus stays on an input while a separate list shows a visual highlight (Combobox, Command Palette).
Use aria-activedescendant instead of roving tabindex when:
- Focus must stay on an input (combobox, searchbox)
- The list is virtualized (not all items are in DOM)
- You need typeahead search
Pattern D: Grid Navigation
For two-dimensional navigation (Calendar, Data Table).
Typeahead Search
For listbox/tree/menu components — typing characters jumps to the matching item:
8. Focus Management
Focus Trapping (Modals)
When building a modal from scratch (no Base UI Dialog), you must trap focus:
Focus Restoration
When an overlay closes, return focus to the element that opened it:
Initial Focus
When an overlay opens, move focus to the first focusable element inside:
9. Controlled / Uncontrolled State
The useControllableState hook (shown in Sections 3 and 4) is the foundation for every interactive primitive. It supports three usage modes:
Implementation Pattern
Props Convention
Always accept three props for controllable values:
Name the callback on{ValueName}Change — e.g., onValueChange, onPageChange, onOpenChange, onCheckedChange.
10. Data Attributes as Styling Hooks
Creating Custom Data Attributes
Expose component state via data-* attributes so consumers can style with CSS:
Rules
-
Boolean attributes: Set to
true/presence orundefined(never"false") -
Enumerated attributes: Use string values
-
Numeric attributes: Set as string (CSS can't read attribute values, but JS can)
Tailwind Targeting
11. CSS Variables for Dynamic Values
Expose computed values as CSS custom properties for consumer-side styling:
Common CSS Variables to Expose
Typing CSS Variables
React's CSSProperties type doesn't include custom properties. Use a type assertion:
12. Click Outside & Escape Dismissal
For overlay-like components built without Base UI's Dialog/Popover.
Combining Both
13. Animation Lifecycle (Without Base UI)
When building overlays from scratch, you need to manage the mount/unmount animation cycle yourself. Base UI provides data-starting-style / data-ending-style and getAnimations() detection — when building from scratch, use this pattern:
CSS Transition Approach (Preferred — Interruptible)
Usage:
CSS Keyframe Approach (For Non-Interruptible Enter/Exit)
If you prefer keyframe animations (like all current Prototyper UI overlays use), apply the animation conditionally:
14. Ref Merging
When your component needs an internal ref AND must accept a user ref:
15. Putting It All Together — Checklist
Before You Start
- Confirmed no Base UI primitive exists for this component
- Checked for headless libraries that handle the state machine
- Identified the WAI-ARIA pattern (https://www.w3.org/WAI/ARIA/apg/patterns/)
- Identified the correct ARIA role from Section 6
HTML & ARIA
- Using semantic HTML elements (
<nav>,<button>,<input>,<ol>) — never<div>when semantic exists - Correct ARIA role on the root or primary interactive element
- All required ARIA states set (
aria-expanded,aria-selected,aria-checked, etc.) - Interactive elements have accessible names (visible text,
aria-label, or<label>) - Decorative elements have
aria-hidden="true" - Live regions (
aria-live) for dynamic content updates -
aria-disabled(not just HTMLdisabled) for custom controls
Keyboard
- All functionality reachable via keyboard
- Correct keyboard pattern (single focus, roving tabindex, or active descendant)
- Arrow keys for navigation within composites
- Home/End for first/last item
- Enter/Space for activation
- Escape for dismissal (overlays)
- Tab order makes sense (only one tab stop per composite widget)
-
event.preventDefault()on handled keys (prevent scroll on arrows)
Focus
-
focus-visible:focus-ringon focusable elements - Focus trapped in modals (
useFocusTrap) - Focus restored when overlays close (
useFocusRestoration) - Initial focus set when overlays open
- Disabled items are either unfocusable or focusable-but-inert (depends on pattern)
State
- Controlled/uncontrolled via
useControllableState - Props follow the
value/defaultValue/onValueChangeconvention - State exposed via data attributes for CSS targeting
- CSS variables for dynamic computed values
Styling
-
data-sloton every rendered element -
cn()merging with userclassNamelast - Semantic tokens (no raw colors, no
dark:utilities) -
hover-only:hover:prefix on all hover states -
motion-safe:active:scale-[0.97]for press feedback -
motion-reduce:transition-noneon all transitions -
motion-reduce:animate-noneon all animations -
no-highlighton interactive elements -
disabled:status-disabledon disableable elements
Form (if applicable)
- Hidden
<input>for form submission withnameprop -
requiredvalidation via hidden required input or constraint API - Works when wrapped by
<Field>component - Error state responds to
data-invalidfrom Field
Structure
-
"use client"as line 1 - Import order: react → cva → icons → cn → local
- Named exports only, single
export { }block - No
React.forwardRef(handle refs via prop oruseMergedRef) - No
export default
16. Cross-References
- Wrapping an existing Base UI primitive: Invoke
/create-componentskill - Animation patterns and exact class strings: Invoke
/animate-uiskill - Design tokens, component selection, and usage patterns: Invoke
/prototyper-uiskill - WAI-ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/patterns/
- Base UI primitives: https://base-ui.com/react/components/{name}