Frontend component design
When Agents add or split components, align with framework habits, namespaces, and Storybook/doc patterns while keeping tests and accessibility in scope.
The SKILL should spell out props, events, and slots (or children composition): what are primitives vs business wrappers, and whether compound components are used.
Require keyboard behavior, focus management, aria-*, and roles; for forms, document controlled vs uncontrolled rules and error presentation—not visuals alone.
Pair with the design-system skill: consistent token, spacing, and breakpoint usage; if the project uses eslint-plugin-jsx-a11y or custom rules, list checks that must pass.
Component API Design Principles and Code Examples
// Component API design: type / default / callback naming conventions
import React from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
// Semantic variant — use variant, not color or type, to express visual intent
variant?: ButtonVariant; // default: 'primary'
size?: ButtonSize; // default: 'md'
disabled?: boolean; // standard HTML semantics
loading?: boolean; // loading state: show spinner, also set disabled
// callbacks use on prefix with explicit types
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
// content: children preferred over label prop
children: React.ReactNode;
// test-friendly: data-testid as a top-level prop
'data-testid'?: string;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', disabled, loading, onClick, children, ...rest }, ref) => (
<button
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
aria-busy={loading}
onClick={onClick}
{...rest}
>
{loading && <span aria-hidden="true">⟳</span>}
{children}
</button>
)
);
Button.displayName = 'Button';
Compound Component pattern — Form + Form.Item composition:
// Compound Component: Form + Form.Item share implicit state via Context
const FormContext = React.createContext<FormContextValue | null>(null);
function Form({ onSubmit, children }: FormProps) {
const [errors, setErrors] = React.useState<Record<string, string>>({});
return (
<FormContext.Provider value={{ errors, setErrors }}>
<form onSubmit={onSubmit}>{children}</form>
</FormContext.Provider>
);
}
// Form.Item: automatically reads error state from Context; associates label with input
Form.Item = function FormItem({ name, label, children }: FormItemProps) {
const { errors } = React.useContext(FormContext)!;
const inputId = `form-${name}`;
const errorId = `${inputId}-error`;
return (
<div className="form-item">
<label htmlFor={inputId}>{label}</label>
{React.cloneElement(children as React.ReactElement, {
id: inputId,
'aria-describedby': errors[name] ? errorId : undefined,
'aria-invalid': !!errors[name],
})}
{errors[name] && <span id={errorId} role="alert">{errors[name]}</span>}
</div>
);
};
// Usage: clean public API without exposing internal Context
<Form onSubmit={handleSubmit}>
<Form.Item name="email" label="Email">
<input type="email" name="email" />
</Form.Item>
</Form>
- Exports and file layout:
indexentry, colocated tests and stories. - Breaking changes: prop renames or default changes need migration notes and codemod hints.
- Parent contract: controlled values, callback naming (
onXxx), and props that must not be forwarded.
Accessibility and prop contract
In the SKILL, separate visible copy from screen-reader-only text: prefer real text nodes; for icon-only controls expose aria-label or aria-labelledby to stable ids.
List common public props / attributes (mapped to props or top-level attributes per framework) explicitly instead of burying them in implementation:
- Naming and description:
aria-label,aria-labelledby,aria-describedby(errors, help text). - State:
disabled,readOnly,aria-invalid,aria-required,aria-busy,aria-expanded,aria-selected. - Role and pattern:
roleonly when native semantics are insufficient; for composites,aria-controls,aria-activedescendant. - Focus: whether
autoFocusis needed, focus trap while open, and focus return on close—document in steps, not only in code.
<label> via htmlFor / for; do not use placeholders as labels. For custom widgets, add a keyboard map (Tab, arrows, Enter, Escape).
From primitives to wrapper
[ Design / use case ]
│
▼
┌─────────────┐ Single responsibility: one user task per component
│ Split duties │── primitives (Button) vs composites (DateRangePicker)
└─────────────┘
│
▼
┌─────────────┐ Props / events / slots captured in SKILL
│ Public API │── controlled vs uncontrolled, defaults, forwardRef if needed
└─────────────┘
│
▼
┌─────────────┐ Roles, labels, keyboard, focus, error copy
│ a11y contract│── align with eslint-plugin-jsx-a11y / team rules
└─────────────┘
│
▼
┌─────────────┐ Story / unit tests / visual regression (per project)
│ Docs & tests │
└─────────────┘
Headless vs Styled, Composability, and Test-Friendliness
// Headless component: provides only logic; consumers control the styling
function useToggle(initialOpen = false) {
const [open, setOpen] = React.useState(initialOpen);
return {
open,
toggle: () => setOpen(o => !o),
// getToggleProps: consumer can extend without built-in styles
getToggleProps: () => ({ onClick: () => setOpen(o => !o), 'aria-expanded': open }),
getContentProps: () => ({ hidden: !open }),
};
}
// Consumer controls styling freely — Headless vs Styled use cases
function Accordion({ title, children }) {
const { open, getToggleProps, getContentProps } = useToggle();
return (
<div>
<button {...getToggleProps()} className="accordion-trigger">{title}</button>
<div {...getContentProps()} className="accordion-content">{children}</div>
</div>
);
}
// Test-friendly: data-testid convention + React Testing Library best practices
function UserCard({ user }: { user: User }) {
return (
<article data-testid="user-card" aria-label={`User ${user.name}`}>
<h2 data-testid="user-name">{user.name}</h2>
<button data-testid="edit-user-btn" onClick={() => onEdit(user.id)}>Edit</button>
</article>
);
}
// Corresponding test: prefer role/label queries; use data-testid as last resort
// test('shows user name', () => {
// render(<UserCard user={{ id: '1', name: 'Alice' }} />);
// expect(screen.getByRole('article', { name: 'User Alice' })).toBeInTheDocument();
// expect(screen.getByTestId('user-name')).toHaveTextContent('Alice');
// });
- Design system: how to reference tokens, spacing, breakpoints, and variants (size / tone); avoid raw color literals.
- Testing: interaction and a11y assertions (role, name) aligned with Vue Test Utils, RTL, etc.
- Performance: list virtualization, memo / useCallback guidelines—avoid blanket micro-optimization.
In-page tool: a11y / Props draft
The script below runs only on this page: enter a component name and interaction patterns to generate a pasteable Props / a11y checklist for a SKILL or PR description.
Adapt output to your framework (Vue/React/Svelte, etc.) with real types and docs; extend the keyboard map with concrete keys and focus order.
SKILL outline
---
name: frontend-component
description: Design accessible, testable UI components per project conventions
---
# Rules
1. Single responsibility: one component, one user task — split complex UIs into child components or colocated private modules
2. Props use semantic variant names (variant/size), not raw colors; callback props use onXxx prefix with explicit types
3. Children preferred over label prop; expose data-testid as a top-level prop for test selectors
4. Use forwardRef + displayName for library-level components to support ref-based operations and DevTools naming
5. Compound Component pattern: share implicit state via Context, expose a clean outer API without leaking internals
# Steps
1. Clarify responsibility: identify the user task, separate primitives (Button) from business composites (DateRangePicker)
2. Define public API: list props, events, slots, defaults, forwardRef if needed, and props that must NOT be forwarded
3. Implement controlled / uncontrolled modes: expose value + onChange for controlled; use internal state with defaultValue for uncontrolled
4. a11y contract: associate labels (htmlFor), add aria-invalid / aria-describedby on errors, implement keyboard map (Tab / arrows / Enter / Escape)
5. For overlays and dialogs: implement focus trap on open, restore focus on close, set aria-modal and aria-labelledby
6. Add a Headless layer for logic-only reuse; apply Styled variants on top via CSS Modules or design-system tokens
7. Write Storybook stories for each variant and state (default, loading, disabled, error)
8. Write RTL tests using role/label queries first; use data-testid only as a last resort
9. For large lists, apply virtualization (react-window / vue-virtual-scroller); apply useMemo / useCallback only when referential equality matters