component
Builds and audits reusable presentational UI components in Storybook — props in, events out, no fetching, routing, global state, or domain types. Designs minimal prop APIs (controlled/uncontrolled, re
SKILL.md.claude/skills/component/SKILL.md
GitHubComponent Engineer
Build presentational components that render pixels from props and emit events. Nothing else.
Mode router
| Mode | Use when | Output |
|---|---|---|
| Author | A new component is needed | File scaffold, prop API, variant matrix, state plan, story plan |
| Audit | An existing component or library needs review | Severity-ranked findings with file:line |
| Stories | A component lacks coverage | Story file with default + variant matrix + per-state + interaction stories |
If the mode is ambiguous, ask.
How components are built in this library
Every component in react-basics-ui follows the same shape. Match it — the
generic guidance further down is the why; this is the how, here.
Folder layout
Components live under src/components/<category>/, with forms/ further split
into inputs/ selection/ date-time/ advanced/ structure/. A folder holds:
Button/
Button.tsx root component — forwardRef + memo
Button.types.ts props and unions (the public type surface)
Button.styles.ts class-map constants (BASE_CLASSES, SIZE_STYLES, …)
ButtonBase.tsx shared internals, when siblings need them
ButtonGroupContext.ts context, when a compound part needs it (.ts, no JSX)
Button.stories.tsx CSF3 stories
Button.test.tsx behaviour tests (see /test for the suffix conventions)
index.ts the public barrel for this folder
Then export the folder from the category barrel (src/components/forms/index.ts),
which the root src/index.ts re-exports. Everything ships from the package root.
Building blocks to reach for first
| Need | Use | Where |
|---|---|---|
| Merge class strings | cn() |
@/lib/cn |
| Compound-component context | createComponentContext() |
@/lib/createComponentContext |
| Controlled/uncontrolled value | useControlledState |
@/hooks |
| Merge a forwarded ref with a local one | useMergedRefs |
@/hooks |
| Stable form/aria ids | generateFormId |
@/lib/generateFormId |
| Focus ring, disabled, transition classes | FOCUS_RING, DISABLED_CLASSES, TRANSITION_COLORS |
@/components/shared/styles |
| Shared prop contracts | ComponentSize, GranularSize, OverlaySize, StatusVariant, AlertVariant, SelectOption |
@/components/shared/types |
Six Base* primitives already carry the hard parts — compose, don't
re-implement: BaseInputField (input chrome, tone, slots), BaseSelectionControl
(checkbox/radio/switch mechanics), BaseOverlayDialog (portal, focus trap, scroll
lock, escape), BaseCardContainer, BaseText, BaseAlertBox.
Size and tone scales — use the shared unions
This library does not use "sm" | "md" | "lg" for component sizes. Import
the canonical scale instead of inventing one:
import type { ComponentSize } from '@/components/shared/types/size';
// ComponentSize = 'small' | 'default' | 'large' ← form controls, buttons, badges
// GranularSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' ← icons, avatars, spinners
// OverlaySize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' ← modals, drawers
Composition patterns in use
forwardRef+memoon essentially every component (143 and 154 files respectively).- Compound components via
Object.assign(28 components):export const Card = Object.assign(CardRoot, { Header, Content, Footer }). - Context via
createComponentContext(26 components) rather than hand-rolledcreateContext+ null checks.
Story titles
meta.title is '<Category>/<Component>', matching the folder category:
Forms/, Data Display/, Layout/, Overlays/, Navigation/, Feedback/,
Typography/, Utility/, Theme/. Nested form folders still title as Forms/X.
Keep the title in step with the folder — a drifting title makes the Storybook
sidebar disagree with the source tree.
The contract
A presentational component must:
- Take props and render. Emit events for state changes the parent owns.
- Be deterministic — same props → same output.
- Reference named visual tokens, not raw values.
- Meet a keyboard-and-screen-reader baseline.
- Render correctly without context providers it does not itself supply.
It must not:
- Fetch, mutate, subscribe, or poll.
- Read from a router, store, query client, or auth context.
- Import domain types or models. Nothing in
src/componentsmay know about a consuming application. - Hold business rules — it does not know why it is being used.
- Read environment, time, or random sources unless an explicit prop opts in.
Component API design
Apply each rule when shaping the public API.
| Rule | Reason |
|---|---|
Prefer children or named slots over items arrays |
Composition keeps the API small as needs grow |
Pick controlled or uncontrolled; if both, pair value + defaultValue (and onChange) |
Mixed conventions cause double-source-of-truth bugs |
| Variants are discriminated unions, not parallel booleans | tone="danger" beats isDanger + isWarning that can collide |
Sizes and tones are enums from shared/types, not numbers or ad-hoc strings |
Numbers leak implementation detail; ad-hoc scales fragment the library |
| Forward refs on inputs, buttons, and anything a parent may focus, scroll to, or measure | Ref access is a public contract |
| Spread remaining native HTML props through to the underlying element | Consumers need aria-*, data-*, id, onBlur, etc. without re-declaring each |
| Default to required props; opt into optional only when there is a real default | "Optional with no default" is a hidden invariant |
| Cap props at ~12; past that, decompose into sub-components | A swollen API signals a component doing two jobs |
Sub-components vs monolithic
Expose sub-parts (Dialog.Header, Dialog.Footer) when consumers legitimately rearrange the parts. Otherwise keep a single closed surface — sub-components are public API and cost more to evolve.
State coverage
Every interactive component addresses each applicable state. Mark N/A explicitly.
| State | Question |
|---|---|
| Default | Resting state with normal props? |
| Hover | Cursor over (or none, for touch-only)? |
| Focus-visible | Keyboard focus ring? |
| Active / pressed | Mid-click / mid-touch? |
| Disabled | Affordance shows why unavailable? |
| Loading | Async action in flight — what is reversible, what is not? |
| Error | Validation or async failure — message location, role="alert" semantics? |
| Success | Post-action confirmation? |
| Empty | No data / placeholder content? |
| Read-only | Same shape, no edit affordance, not styled as broken? |
| Indeterminate | Tri-state controls (checkbox, progress)? |
| Selected | Item is part of a selection set? |
Style discipline
Components must not contain raw hex, rgb, hsl, px values, font families, or magic durations. Reference named tokens. If a needed token does not exist, propose a name and surface it as an Open Question — do not hardcode the value and move on.
Accessibility baseline
- Correct semantic element first; ARIA only to fill gaps the element cannot.
- Role, accessible name, and value verifiable from the rendered DOM.
- Keyboard reachable; Tab order matches visual order; focus-visible ring is a token.
- Hit target ≥ 44×44 logical pixels for primary interactive elements.
- No information by color alone — pair with icon, text, or shape.
- Honor reduced-motion: non-essential motion is suppressed, not just slowed.
- Status changes from async actions announce via live region or moved focus.
Storybook stories
One story file per component
Co-locate the story file beside the component. meta.title is '<Category>/<Component>' matching the folder category (e.g. Forms/Button, Data Display/Table).
What to write
| Story type | When | Notes |
|---|---|---|
| Default | Always | Canonical use with realistic args |
| Variants matrix | Per variant axis (size, tone, density) | One story per axis, or a single story rendering the matrix |
| State | One per state from the coverage table | Story name = state name |
| Interaction | One per behavior (open, submit, navigate keys) | Use play with userEvent + expect |
| Edge content | Long strings, RTL, narrow widths, dense data | Catches layout fragility |
Story shape
argsdefine defaults users tweak via controls.argTypesconstrain controls — disable controls for props that should not be tweaked.parameters.a11yenables the a11y addon and turns rules on for the component's pattern.parameters.docs.description.storygives one-sentence intent per story.- Mock everything the component would not have in production — but if the component needs mocks to render, that is a business-logic leak. Fix the component, not the story.
What does not belong in stories
- Real network calls. Real router. Real auth.
- Application-specific theme wrappers — register the theme in
.storybook/preview.tsonce. - Snapshot tests masquerading as stories — stories are documentation + interactive playground; assertions live in
playor sibling test files.
Audit dimensions
Run in order. Each finding: location, observation, impact, suggestion.
1. Business-logic leaks
Search the component file for imports and calls that signal non-presentational behavior:
grep -nE "useQuery|useMutation|useSwr|useRouter|useNavigate|useStore|useSelector|useDispatch|fetch\(|axios|/api/|features/" <component>
Each hit moves out. In a library there is no container layer to hide it in — either lift it to a prop/callback the consumer supplies, or extract it into a co-located hook that takes plain arguments.
2. State coverage gaps
For each component, confirm a story exists per applicable state. Missing states are findings.
3. Token leaks
grep -nE "#[0-9a-fA-F]{3,8}\b|rgb\(|hsl\(" <component>
grep -nE "(padding|margin|gap|font-size|line-height|border-radius)\s*:\s*[0-9]" <component>
Each hit: replace with a token, or — if no token exists — propose one.
4. Prop API smells
| Smell | Fix |
|---|---|
Parallel booleans that can collide (isPrimary, isDanger, isGhost) |
Single discriminated union prop |
| > 12 props | Decompose into sub-components or compose primitives |
| Optional props with no defined default | Make required or document the default |
Same concept named differently across siblings (open vs isOpen vs visible) |
Pick one and align the library |
| Props that take JSX and objects for the same slot | Pick one shape per slot |
5. Reachability
- Native HTML props pass through? (Ref a small test: does
aria-label,data-*,id,onBlurreach the underlying element?) - Refs forward? (Try wrapping in a focus-management helper.)
Severity
- Critical — component cannot be reused (business-logic import, broken keyboard path, no focus ring, missing role).
- High — state gap a real user hits; visible token drift; API collision.
- Medium — polish gap, naming inconsistency, missing edge-content story.
- Low — taste call.
Author workflow
- Justify existence. Why does no existing primitive (or composition of primitives) satisfy this?
- Define the API. Props table with types, defaults, and notes. Events with payloads. Slots. Refs.
- Enumerate variants. Size × tone × density (whatever axes apply).
- Cover states. Walk the State Coverage table; mark each applicable or N/A.
- A11y contract. Role, keyboard map, focus behavior, announcements.
- Tokens. Name every visual token the component consumes; flag any that need to be added.
- Story plan. Default, variants matrix, one story per state, interaction stories per behavior, edge-content story.
- Open questions. Tag
[blocker],[clarify],[nice-to-have]. - Definition of Done. Label the spec Ready / Iterate / Blocked.
Definition of Done
Apply at the end of every mode.
- 0 business-logic imports in the component file
- 0 raw hex / rgb / hsl / px values
- 1 story per applicable state + 1 per variant axis
- ≥ 1 interaction story per public behavior
- A11y addon clean on every story (or known violation explicitly waived with rationale)
- Ref forwarded; native props spread through
- Public API typed and exported; defaults documented
If any item fails, label Iterate and list the gap. If a blocker is unresolved, label Blocked.
Output templates
Component spec
# Component: <Name>
## Why this exists
<Reuse walk-through — what was tried, why it didn't fit>
## API
| Prop | Type | Default | Notes |
|------|------|---------|-------|
| ... | ... | ... | ... |
Events: <list with payloads>
Slots: <list>
Ref: <forwarded to which element>
## Variants
- size: sm | md | lg
- tone: neutral | success | danger | ...
- density: comfortable | compact
## States
<one row per applicable state with visual + behavior + a11y note>
## A11y contract
- Role: ...
- Keyboard: ...
- Focus: ...
- Announcements: ...
## Tokens consumed
- color: ...
- spacing: ...
- typography: ...
- radius / shadow / motion: ...
## Story plan
- Default
- Variants matrix: <axes>
- States: <list>
- Interactions: <list with what each play() asserts>
- Edge content: <list>
## Open questions
1. [blocker] ...
2. [clarify] ...
## Status
**[Ready / Iterate / Blocked]**
Audit report
# Component Audit: <name or directory>
> Files examined: <clickable paths>
## Findings
### <Dimension>
**[Severity] — <title>**
- Location: file:line
- Observation: <what is there>
- Impact: <what a consumer or end user notices>
- Suggestion: <concrete change>
[repeat per finding, grouped by dimension]
## Summary
| Severity | Count |
|----------|-------|
| Critical | N |
| High | N |
| Medium | N |
| Low | N |
## Status
**[Ready / Iterate / Blocked]**