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: index entry, 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: role only when native semantics are insufficient; for composites, aria-controls, aria-activedescendant.
  • Focus: whether autoFocus is needed, focus trap while open, and focus return on close—document in steps, not only in code.
For Agents: associate forms with real <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.

Parameters

Used for list title and example prefix; leave empty for a generic placeholder name.

Interaction patterns (multi-select)

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

Back to skills More skills