Skip to main content
Back to the field guide

A field guide to the /prism-component skill

AI React Component Generator (Accessible by Default)

Most AI-generated components miss states and accessibility. /prism-component builds typed, reusable components with every state, ARIA coverage, and keyboard interactions.

Prism · Frontend/DX11 min readMarch 20, 2026

A component that does its job has more surface area than the screenshot suggests. Take a button. The visible part is the rectangle with the label. The invisible parts are: the disabled state with the right cursor and the right focus behavior, the loading state with the spinner that does not change the button's width, the focus ring that is visible against every background the button might land on, the keyboard activation on Enter and Space, the ARIA labeling for screen readers when the button is icon-only, the hover and active states that respect the user's reduced-motion preference, the typed props that catch misuse at the editor rather than at runtime, and the prop documentation that lets the next person to import it know how to use it correctly. Every component carries that surface area, and every component generated quickly tends to skip most of it.

The result is the codebase full of components that look right in Storybook and are subtly broken in production. The button that loses its focus ring on dark backgrounds. The dropdown that traps keyboard focus when it opens but does not return focus to the trigger when it closes. The dialog that scrolls the page underneath when the user scrolls inside it. The combobox that announces the wrong item to screen readers. None of these are deal-breakers individually. Cumulatively they make the application unusable for keyboard-only users and frustrating for everyone else, and the cost of fixing them later is much higher than the cost of getting them right the first time. The /prism-component skill is built for the right-the-first-time version: every component ships with the full state matrix, the ARIA coverage, the keyboard interactions, and the typed props, because each of those is part of what "a component" actually means.

Why generalist AI ships incomplete components

Generalist tools optimize for the visible output. A prompt that says "build a button component" produces a function that returns a styled element with a label. The function looks right. It is missing the disabled state because the prompt did not mention it. It is missing the loading state because the prompt did not mention it. The ARIA attributes are absent or generic because the model defaults to whatever was most common in its training data, which is rarely the correct attribute set for the specific component. The keyboard interactions are whatever the underlying element provides by default, which is fine for a <button> and broken for a custom component built on a <div>. The component appears in the codebase, gets imported in five places, and the gaps are discovered one at a time over the next several months.

Cursor and Copilot have a different version of the same problem. They suggest completions for the line you are typing, which is excellent for filling in the JSX of a component you have already structured and unhelpful for designing the component's contract. The contract (which props exist, which states are visible, which keyboard interactions are supported) has to be designed up front, and autocomplete cannot design it. The result is a component whose contract drifts as it gets extended: a new prop added without a default, a new state branch added without a corresponding type variant, an ARIA attribute added inconsistently across instances. Each addition feels small; the cumulative effect is a primitive that everyone in the team treats with caution because nobody fully trusts its behavior.

What a complete component actually requires

A complete component has five parts. First, the typed contract: the props with their types, defaults, required vs optional, with TypeScript discriminated unions where the prop set varies by variant. Second, the state matrix: every visible state (default, hover, focus, active, disabled, loading, error, empty if applicable) implemented and demonstrated in Storybook. Third, the keyboard interactions: which keys do what, with the discipline that interactive elements respond to Enter and Space, that focusable elements have visible focus indicators, and that focus is managed correctly across complex interactions like menus and dialogs. Fourth, the ARIA coverage: roles, labels, descriptions, and live regions where applicable, calibrated to what the component actually does rather than what the model guesses. Fifth, the documentation: prop descriptions, usage examples, and accessibility notes, embedded in the source so the editor surfaces them on import.

These parts are not optional. A component that has the visual states without the keyboard interactions excludes keyboard users. A component that has ARIA without the right roles makes screen readers worse, not better. A component without typed props produces editor-time bugs that show up at runtime in the wrong contexts. The discipline of building the parts together is the discipline of treating components as primitives that the rest of the codebase will rely on, which is exactly what they are. The cost of skipping the discipline is paid by the next ten places that import the component.

How /prism-component works

Step one: read the design system

Before generating any component, /prism-component reads the project's existing design system to identify the patterns: the styling approach (Tailwind, CSS modules, vanilla-extract), the component library style (shadcn-style headless, Radix primitives, MUI), the token system (colors, spacing, typography), the icon library, and the existing component conventions. The new component matches those conventions rather than introducing new ones. A button generated with /prism-component in a shadcn project looks and feels like the existing shadcn buttons; in a Material project, it follows the Material conventions.

Step two: design the contract

The component's typed contract is designed before any markup is written. The props are listed with their types, defaults, and the variants they enable. Where the prop set varies by variant (an icon button has different required props from a text button), the contract uses TypeScript discriminated unions to enforce the variation at the type level. The contract is presented for review before code is generated, so the team can push back on prop names, default values, and required-vs-optional decisions while the cost of changing them is still cheap.

Step three: build the state matrix

Each visible state in the matrix is implemented as part of the component: default, hover, focus, focus-visible, active, disabled, loading, and any domain-specific states (error, empty, success). Each state has a Storybook entry so the team can review the visual treatment. The state implementation respects the user's preferences: prefers-reduced-motion disables motion, prefers-color-scheme adapts where the theme system supports it. Focus rings are visible against every background the component might land on; this is verified by axe-core and a manual contrast check during generation.

Step four: keyboard and ARIA

The keyboard interactions are part of the component contract. Buttons activate on Enter and Space; menus open and close with the arrow keys, Escape, and Home/End; dialogs trap focus on open and return focus on close; comboboxes navigate options with arrows and select with Enter. The ARIA roles, labels, and descriptions are applied to match the component's behavior, not as decoration. The /prism-component skill runs axe-core on the output and surfaces any violations as part of the review; a component does not ship until the violations are zero.

The single most common ARIA mistake is using aria-label on an interactive element that already has visible text. The label overrides the visible text for screen readers, which means visual users see one thing and screen reader users hear another. /prism-component refuses this combination and uses aria-describedby for additional context instead.

Tonone's /prism-component skill builds typed, accessible UI components with the full state matrix, keyboard interactions, ARIA coverage, and prop documentation.

When to use /prism-component, and when not to

/prism-component is the right call when adding a reusable component to a design system or application that will appear in multiple places. The signal is when the same UI pattern is starting to be repeated across screens (a custom dropdown, a custom modal, a custom date picker), or when an existing component is missing states, accessibility attributes, or typed props. The skill is also the right call when Form has produced a visual spec and the next step is the implementation.

Skip the skill for one-off UI specific to a single screen (use /prism-ui for full screen implementation), for purely decorative elements with no interaction, and for static content blocks that do not need state management. For chart components specifically, /prism-chart is calibrated to data visualization with the right accessibility patterns for chart data.

CapabilityTononeGeneralist chatbotCursor / Copilot
Reads existing design system before generatingYes, matches Tailwind/CSS, tokens, conventionsPicks defaults from training dataSuggests within current line context
Typed contract with discriminated unionsYes, prop variants enforced at type levelPlain interface or any typesWhatever exists in current file
Full state matrix implementedDefault, hover, focus, active, disabled, loading, errorDefault state only unless promptedDefault state only
Keyboard interactions per WAI-ARIA APGYes, role-appropriate keyboard handlingDefault browser behavior onlyDefault browser behavior only
axe-core passes with zero violationsYes, verified during generationNot checkedNot checked

A worked example: building an icon button

Suppose the brief is: build an IconButton component for the design system. Run /prism-component and the contract is the first output, before any code.

tsx
// Contract for review
//
// IconButton: a button with only an icon, no visible text.
// Sizes: sm | md | lg. Variants: primary | secondary | ghost | danger.
// Always requires `aria-label` because there is no visible text.
//
// type IconButtonProps = {
//   icon: ReactNode;
//   'aria-label': string;
//   size?: 'sm' | 'md' | 'lg';
//   variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
//   loading?: boolean;
//   disabled?: boolean;
//   onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
// } & ButtonHTMLAttributes<HTMLButtonElement>;

import { forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';

const iconButtonVariants = cva(
  'inline-flex items-center justify-center rounded-md transition-colors ' +
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
    'focus-visible:ring-offset-2 focus-visible:ring-offset-background ' +
    'disabled:opacity-50 disabled:pointer-events-none ' +
    'motion-reduce:transition-none',
  {
    variants: {
      variant: {
        primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        danger: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
      },
      size: {
        sm: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',
        md: 'h-10 w-10 [&_svg]:h-5 [&_svg]:w-5',
        lg: 'h-12 w-12 [&_svg]:h-6 [&_svg]:w-6',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
  }
);

export interface IconButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof iconButtonVariants> {
  /** The icon to render. Must be an SVG or component returning one. */
  icon: React.ReactNode;
  /** Accessible name. Required because the button has no visible text. */
  'aria-label': string;
  /** Show a loading spinner in place of the icon. */
  loading?: boolean;
}

export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
  ({ icon, loading, disabled, variant, size, className, ...props }, ref) => (
    <button
      ref={ref}
      type="button"
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      className={cn(iconButtonVariants({ variant, size }), className)}
      {...props}
    >
      {loading ? (
        <Loader2 className="animate-spin motion-reduce:animate-none" />
      ) : (
        icon
      )}
    </button>
  )
);
IconButton.displayName = 'IconButton';

// + IconButton.stories.tsx (every variant x size x state)
// + IconButton.test.tsx (keyboard activation, aria-busy on loading)

The output is the component plus the Storybook entries plus the tests. Every state is verifiable, every keyboard interaction is tested, and the typed contract makes misuse visible at the editor. The aria-label is required by the type system because the component has no visible text; the next engineer who tries to import the component without one gets a TypeScript error rather than a screen-reader regression.

/prism-component builds primitives. For composing primitives into full screens, /prism-ui is the right call. For internal admin dashboards with dense data, /prism-dashboard is calibrated to that style. For the design specification that becomes the input to /prism-component, /form-component produces the visual spec.

Install

/prism-component ships with the Prism agent in the Tonone for Claude Code package. Install Tonone, invoke /prism-component from any Claude Code session inside the project, and the skill produces typed, accessible components that match the existing design system.

1. Add to marketplace

$ claude plugin marketplace add tonone-ai/tonone

2. Install Prism

$ claude plugin install prism@tonone-ai

Components are the parts of the codebase that other parts depend on, which is why the cost of getting them wrong compounds. The skill is built so the discipline is the default, and the components stay trustworthy as the system grows.

Frequently asked questions

What does /prism-component do?
It builds typed, accessible UI components with the full state matrix (default, hover, focus, active, disabled, loading, error), keyboard interactions per WAI-ARIA APG, ARIA coverage, and Storybook entries plus tests.
How is /prism-component different from a generalist AI?
A generalist produces the default state and skips the rest. /prism-component reads the existing design system, designs the typed contract first, generates every state, runs axe-core, and produces the documentation and tests.
When should I use /prism-component?
When adding a reusable component that will appear in multiple places, or when an existing component is missing states, accessibility, or typed props. Skip it for one-off UI specific to a single screen.
What styling approaches does /prism-component support?
Tailwind CSS (with class-variance-authority for variants), CSS modules, vanilla-extract, and styled-components. The skill matches the project's existing approach rather than introducing a new one.
Does /prism-component generate Storybook entries?
Yes. Every visible state of the component gets a Storybook entry so the team can review the visual treatment per state. Storybook is the default; if the project uses a different documentation tool, the skill matches it.
How do I install /prism-component?
Install Tonone for Claude Code via the get-started guide at tonone.ai/get-started. /prism-component ships with the Prism agent and is invoked as a slash command in any Claude Code session. Tonone is free and MIT-licensed.
Is /prism-component free?
Yes. The skill is part of Tonone, which is MIT-licensed. The only cost is Claude Code token usage during the work.
Does /prism-component work with Vue or Svelte?
Yes. The skill detects the project's framework and matches it. React is the most common path; Vue 3 (Composition API) and Svelte 5 (runes) are also supported with the same accessibility and typing standards.

Pairs well with