Build a Design System with Storybook
Build an accessible Button component with variants, docs, and tests.
Introduction
30 Second Summary
Every polished app you use has buttons, toggles, and controls that look and behave consistently no matter where they appear. Behind the scenes, a design system makes that possible.
In this project, you will build a mini design system from scratch. You will create an accessible, variant-rich Button component using headless primitives, style it with a powerful variant engine, document it with live previews, and prove its quality with automated tests.
What You'll Build
You will have a fully tested Button component running in a live Storybook instance where you can toggle between color schemes, sizes, and disabled states, then run a single test suite that proves it handles clicks correctly and meets accessibility standards.
By the end of this project, you'll have:
- A Button component built on Base UI's accessible primitive, styled with three color variants, three sizes, and compound disabled states that automatically adjust per color. You can render every combination live in the browser and watch them respond to clicks and keyboard navigation.
- A Storybook documentation page written in MDX that shows live component previews, a full props table, and usage guidelines. Another developer can open it and immediately understand how to use your Button.
- Automated interaction tests that simulate a user clicking the Button and verify the click handler fires. Plus accessibility tests powered by axe-core that catch a real WCAG color-contrast violation, which you then fix to prove the testing loop works.
- Secret Mission: Build a multi-slot Switch/Toggle component using tailwind-variants slots and Base UI's Switch primitive, with per-part styling that visually animates between checked and unchecked states.
Are there any prerequisites?
You should be familiar with React (components, props, JSX) and comfortable running commands in a terminal. You will also need Node.js installed on your machine.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Scaffold the Project
Building a design system requires several tools working together. You need a bundler to compile your code, a CSS framework for styling, accessible component primitives, a variant engine to manage visual options, and a documentation environment to showcase everything.
In this step, you will scaffold a Vite project with React and TypeScript, install Tailwind CSS, Base UI, and tailwind-variants, then initialize Storybook as your component documentation and testing environment.
In this step, get ready to:
- Create a Vite React TypeScript project and install all dependencies.
- Configure Tailwind CSS with the Vite plugin.
- Initialize Storybook and remove default example stories.
Create the Vite project and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new Vite React TypeScript project by running:
npm create vite@latest design-system -- --template react-ts
You should see output confirming the project was scaffolded, ending with instructions to install dependencies.
What did this command do?
This created a design-system/ folder on your Desktop containing a pre-configured React + TypeScript project with Vite as the build tool. The --template react-ts flag told Vite exactly which template to use, skipping interactive prompts.
- Move into the project and install all the packages you need by running:
cd design-system
npm install tailwindcss @tailwindcss/vite @base-ui/react tailwind-variants tailwind-merge
You should see npm download and install the packages, finishing with a summary of added dependencies.
What are these packages?
- tailwindcss and @tailwindcss/vite provide utility-first CSS with a native Vite integration.
- @base-ui/react gives you headless, accessible component primitives (buttons, switches, dialogs) with zero built-in styling.
- tailwind-variants is the variant engine that lets you define color, size, and compound variant combinations for your components.
- tailwind-merge intelligently resolves conflicting Tailwind classes when users pass custom className overrides.
Install failed or timed out?
- Check your internet connection. All packages come from the npm registry.
- Make sure you ran cd design-system first. Running npm install outside the project folder fails because there is no package.json there.
Still stuck? Why is my npm install failing in the design-system folder?
Configure Tailwind CSS
Tailwind CSS needs two small configuration changes. You wire up the Vite plugin and replace the default CSS file with Tailwind's import directive.
- Open vite.config.ts in your editor and replace its entire contents with this code:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
- Open src/index.css and replace all of its contents with this single line:
@import "tailwindcss";
What do these changes do?
The tailwindcss() plugin in vite.config.ts tells Vite to process Tailwind's utility classes at build time. The @import "tailwindcss" directive in your CSS file activates Tailwind's base styles, components, and utilities.
You replaced the default Vite CSS because it contained placeholder styles you do not need. Your design system will use Tailwind utilities exclusively.
- Save both vite.config.ts and src/index.css.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is what each file should contain in full:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
@import "tailwindcss";
Initialize Storybook
Storybook provides a component workshop where you can view, test, and document your design system components in isolation.
- Initialize Storybook in your project by running:
npx storybook@latest init
What will this command ask you?
Storybook will present interactive prompts. When asked if you are new to Storybook, select No (this skips the onboarding tour). When asked what configuration to install, select Recommended (includes docs, testing, and accessibility features).
Storybook auto-detects your React + Vite setup and configures the @storybook/react-vite framework automatically. It may also start the dev server when finished. If it does, press Ctrl+C to stop it so you can continue with the next step.
Once the command finishes, you should see new .storybook/ and src/stories/ folders in your project.
Storybook init failed?
- Make sure you are inside the design-system/ folder. Storybook needs a package.json to detect your project type.
- If prompted about a package manager, select npm.
Need more help? Storybook init is failing in my Vite React TypeScript project. Help me debug.
- Remove the default example stories that Storybook generated by running:
macOS
rm -rf src/stories
Windows
Remove-Item -Recurse -Force src\stories
- Confirm the folder is gone by running:
ls src/
You should see files like App.tsx, main.tsx, and index.css listed, but no stories/ directory.
Verify Storybook launches
Everything is installed and configured. Before you run Storybook, think about what you expect to see. The example stories are gone, so will Storybook still load?
- Start Storybook by running:
npm run storybook
Storybook compiles your project and opens a browser tab at localhost:6006. You should see the Storybook welcome page. The sidebar will be empty since you removed the example stories. That is exactly right.
Browser did not open or build failed?
- If the browser did not open automatically, navigate to http://localhost:6006 manually.
- If you see a build error mentioning tailwindcss, double check that vite.config.ts imports tailwindcss from '@tailwindcss/vite' and includes tailwindcss() in the plugins array.
- If port 6006 is already in use, stop whatever process is using it or check if Storybook started earlier and is still running.
Still stuck? Help me fix my Storybook build error.
Why remove the example stories?
Storybook's default stories are generic demos unrelated to your design system. Removing them gives you a clean slate. Every story you write from now on will document a real component you build.
Your development environment is fully set up. Next up, you will build a Button component with Base UI and tailwind-variants, giving your design system its first real piece.
Build the Button Component
Your Storybook is running, but there is nothing to show yet. Time to build the first real component in your design system.
The approach you will use separates two concerns cleanly. Base UI's Button primitive handles all the invisible behavior: keyboard navigation, ARIA roles, and disabled-state management. tailwind-variants handles the visual side: a typed variant system that generates Tailwind classes from props like color, size, and disabled.
In this step, get ready to:
- Define the Button's variant system with color, size, and disabled styling.
- Create a Button component that wraps Base UI's accessible primitive.
- Write Storybook stories to preview every variant combination.
Define the Button's variant system
The variant system is where you declare every visual combination your Button supports. You will define base styles shared by all buttons, then add variant axes for color, size, and disabled state.
- Create the component folder by running this command:
mkdir -p src/components/Button
- Confirm the folder exists by running:
ls src/components
You should see Button in the output.
- Create a file called Button.variants.ts inside src/components/Button/ and add the following code:
import { tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 select-none',
variants: {
color: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 active:bg-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
},
size: {
sm: 'text-sm px-3 py-1.5',
md: 'text-base px-4 py-2',
lg: 'text-lg px-6 py-3',
},
disabled: {
true: 'cursor-not-allowed opacity-50',
},
},
defaultVariants: {
color: 'primary',
size: 'md',
disabled: false,
},
});
export type ButtonVariantProps = Parameters<typeof buttonVariants>[0];
What does this code do?
- tv() creates a variant function that generates Tailwind class strings from props.
- base defines classes every Button gets regardless of variant (layout, font, focus ring, transitions).
- color provides three visual intents: primary for main actions, secondary for alternatives, danger for destructive actions.
- size controls padding and font size across three tiers.
- defaultVariants sets what the Button looks like when no props are passed (primary, medium, enabled).
- ButtonVariantProps extracts the prop types from your tv() config so TypeScript knows which combinations are valid.
This is a solid foundation, but there is one gap. When a button is disabled AND primary, you want it to look faded in blue, not generic gray. Compound variants let you target specific combinations.
- In Button.variants.ts, find this line:
defaultVariants: {
- Add the following compoundVariants array directly above it:
compoundVariants: [
{
color: 'primary',
disabled: true,
class: 'bg-blue-300 hover:bg-blue-300 active:bg-blue-300',
},
{
color: 'secondary',
disabled: true,
class: 'bg-gray-100 text-gray-400 hover:bg-gray-100 active:bg-gray-100',
},
{
color: 'danger',
disabled: true,
class: 'bg-red-300 hover:bg-red-300 active:bg-red-300',
},
],
What are compound variants?
Compound variants activate only when multiple conditions match simultaneously. Each entry says: when color is X AND disabled is true, override with these specific classes.
This lets you give each disabled color its own muted shade instead of a single generic disabled style.
- Save Button.variants.ts.
- Verify the file compiles cleanly by running:
npx tsc --noEmit --project tsconfig.app.json
You should see no output, which means zero TypeScript errors.
Seeing a type error?
- Check that tailwind-variants is installed. Run npm ls tailwind-variants to confirm.
- Ensure your compoundVariants array has a trailing comma before defaultVariants.
- help me fix a TypeScript error in my Button.variants.ts file
Create the Button component
With the variant system defined, you need a React component that connects it to Base UI's accessible Button primitive. The component will accept variant props, pass them to buttonVariants() to generate classes, and forward everything else to Base UI.
- Create Button.tsx inside src/components/Button/ with the following code:
import * as React from 'react';
import { Button as BaseButton } from '@base-ui/react/button';
import { buttonVariants, type ButtonVariantProps } from './Button.variants';
export interface ButtonProps
extends Omit<React.ComponentPropsWithoutRef<'button'>, 'disabled'>,
ButtonVariantProps {
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ color, size, disabled, className, children, ...props }, ref) => {
return (
<BaseButton
ref={ref}
disabled={disabled ?? false}
className={buttonVariants({ color, size, disabled, class: className })}
{...props}
>
{children}
</BaseButton>
);
},
);
Button.displayName = 'Button';
What does this code do?
- ButtonProps merges standard button HTML attributes with your variant props (color, size, disabled).
- The Omit<..., 'disabled'> removes the native boolean disabled type so tailwind-variants' version (which also accepts undefined) takes priority.
- disabled is wired to TWO places: Base UI (which adds aria-disabled and blocks interaction) and buttonVariants() (which applies your visual disabled styles).
- The class option in buttonVariants() merges any extra className passed from outside, allowing per-instance overrides.
- Save Button.tsx.
- Create the barrel export file index.ts inside src/components/Button/ with the following code:
export { Button } from './Button';
export type { ButtonProps } from './Button';
export { buttonVariants } from './Button.variants';
export type { ButtonVariantProps } from './Button.variants';
- Save index.ts.
- Verify both files compile by running:
npx tsc --noEmit --project tsconfig.app.json
No output means no errors. Your component is ready but invisible until you give Storybook something to render.
Seeing an import error?
- Confirm @base-ui/react is installed by running npm ls @base-ui/react.
- Check that the import path in Button.tsx uses '@base-ui/react/button' (lowercase button).
- help me fix an import error in my Button component
Write Storybook stories for every variant
Stories are how Storybook discovers and renders your component. Each exported story shows the Button in one specific variant combination.
- Create Button.stories.tsx inside src/components/Button/ and add the meta configuration:
import type { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
component: Button,
args: {
children: 'Button',
onClick: fn(),
},
argTypes: {
color: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
disabled: {
control: 'boolean',
},
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
What does the meta configuration do?
- title sets where the Button appears in Storybook's sidebar (under Components > Button).
- args defines shared default props. fn() creates a spy function so you can track clicks later.
- argTypes creates interactive controls in the Storybook panel so you can toggle variants live.
- Add the color variant stories below the type Story line:
export const Primary: Story = {
args: {
color: 'primary',
children: 'Primary Button',
},
};
export const Secondary: Story = {
args: {
color: 'secondary',
children: 'Secondary Button',
},
};
export const Danger: Story = {
args: {
color: 'danger',
children: 'Danger Button',
},
};
- Save Button.stories.tsx.
- Switch to your browser where Storybook is running at localhost:6006.
The page refreshes automatically. You should see a Components section in the sidebar with Button underneath it, and three stories: Primary, Secondary, and Danger.
Don't see the Button in the sidebar?
- Make sure you saved the file. Unsaved changes will not trigger a reload.
- Check that your file is named Button.stories.tsx (not .ts). Storybook needs the .stories suffix to discover it.
- If the sidebar is empty, stop Storybook with Ctrl+C and re-run npm run storybook.
- help me get my Button stories to appear in Storybook
Now add the size and disabled stories. These demonstrate the remaining variant axes.
- In Button.stories.tsx, add the following stories below your Danger export:
export const Small: Story = {
args: {
size: 'sm',
children: 'Small',
},
};
export const Large: Story = {
args: {
size: 'lg',
children: 'Large',
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: 'Disabled',
},
};
- Finally, add two more stories for testing (you will use these in a later step). Add them below the Disabled export:
export const ClicksHandler: Story = {
args: {
children: 'Click Me',
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalledOnce();
},
};
export const LowContrast: Story = {
args: {
children: 'Hard to Read',
className: 'bg-gray-100 text-gray-300',
},
};
What do these last two stories do?
- ClicksHandler has a play function that simulates a click and asserts onClick was called. This is an interaction test you will verify in a later step.
- LowContrast deliberately uses poor color contrast. You will use this to test accessibility auditing later.
- Save Button.stories.tsx.
Before you check the browser, what do you expect to see in the Storybook sidebar now?
Switch to your browser. You should see eight stories listed under Components > Button: Primary, Secondary, Danger, Small, Large, Disabled, Clicks Handler, and Low Contrast. Click each one to see your Button rendered in that variant combination.
What should I see?
Primary shows a blue button. Secondary shows a gray button with dark text. Danger shows a red button. Small and Large demonstrate different padding and font sizes. Disabled shows a faded primary button with reduced opacity.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files in src/components/Button/.
ⓧ I'd like to double check the full code
Your src/components/Button/ folder should contain four files. Confirm each is present and matches the chunks you built above:
- Button.variants.ts contains the tv() definition with base, variants (color, size, disabled), compoundVariants (three entries), defaultVariants, and the ButtonVariantProps type export.
- Button.tsx imports from Base UI and the variants file, defines ButtonProps, and renders BaseButton with the generated class string.
- index.ts re-exports Button, ButtonProps, buttonVariants, and ButtonVariantProps.
- Button.stories.tsx has the meta config, then eight story exports: Primary, Secondary, Danger, Small, Large, Disabled, ClicksHandler, LowContrast.
Scroll up and compare each file to the code blocks above. If anything is missing, add it and save.
Your Button component is live in Storybook with a full variant system. Next up, you will document it with rich MDX so other developers know how and when to use each variant.
Document the Button with MDX
Your Button component works and looks great across all its variants. But right now, another developer joining your project would have to click through individual stories to understand what the Button can do.
A design system without documentation is just a folder of components. In this step, you will write an MDX documentation page that renders live component previews alongside prose, a props table, and usage guidelines. This makes the Button discoverable and usable by anyone on your team.
In this step, get ready to:
- Create an MDX file that attaches to your Button stories via Doc Blocks.
- Add variant documentation with live Canvas previews and a props table.
- Write usage guidelines explaining when to use each variant.
Create the MDX file with Doc Blocks
Storybook's Doc Blocks are special components you embed in MDX files to render live stories, props tables, and source code. The three you will use are Meta (attaches the MDX page to your stories), Canvas (renders a story with an interactive toolbar), and ArgTypes (generates a static props table from your component's types).
- Create a new file called Button.mdx inside src/components/Button/ and paste the following code:
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';
<Meta of={ButtonStories} />
# Button
A versatile button component built on Base UI's accessible primitive, styled with tailwind-variants.
The Button handles keyboard interaction, ARIA roles, and disabled state management automatically via Base UI. You control the visual appearance through variant props.
## Preview
<Canvas of={ButtonStories.Primary} />
## Variants
### Color
Use `color` to communicate intent:
- **primary** - Main actions (submit, confirm)
- **secondary** - Alternative actions (cancel, back)
- **danger** - Destructive actions (delete, remove)
<Canvas of={ButtonStories.Secondary} />
<Canvas of={ButtonStories.Danger} />
What does this code do?
- The two import lines pull in Storybook's Doc Block components and all your Button stories as a namespace (ButtonStories).
- <Meta of={ButtonStories} /> attaches this MDX page to the Button's story file. Storybook displays it as a "Docs" entry in the sidebar under Components/Button.
- Each <Canvas of={...} /> renders the named story with a toolbar and source code toggle, so readers see the live component and can inspect its markup.
- Save Button.mdx.
- Switch back to your browser with Storybook running. In the sidebar, expand Components then Button and click Docs.
- You should see the "Button" title, the two description paragraphs, and a live Primary Button rendered in the Preview section.
Don't see a Docs entry in the sidebar?
- Make sure the file is named exactly Button.mdx (capital B) and lives in the same folder as Button.stories.tsx.
- Check the import path. It must be './Button.stories' with no file extension. Storybook resolves the CSF file automatically.
- Try stopping and restarting Storybook. MDX files added after startup sometimes require a fresh build.
Why isn't my MDX docs page showing in the Storybook sidebar?
Now add the remaining variant sections. These document the size and disabled options, and include the props table that Storybook auto-generates from your component's TypeScript types.
- In Button.mdx, add the following below the <Canvas of={ButtonStories.Danger} /> line:
### Size
Use `size` to fit the button into different contexts:
- **sm** - Compact UI, toolbars
- **md** - Default, most use cases
- **lg** - Hero sections, prominent CTAs
<Canvas of={ButtonStories.Small} />
<Canvas of={ButtonStories.Large} />
### Disabled
Pass `disabled` to prevent interaction. The button remains focusable for screen reader accessibility but visually indicates it cannot be activated.
<Canvas of={ButtonStories.Disabled} />
## Props
<ArgTypes of={ButtonStories} />
What does this code do?
- The Size subsection documents each size option with its intended use case, then renders the Small and Large stories as live previews.
- The Disabled subsection explains the accessibility benefit. The button stays focusable for screen readers even when disabled.
- <ArgTypes of={ButtonStories} /> reads the TypeScript interface from your Button component and renders a table listing each prop, its type, default value, and description.
- Save Button.mdx and refresh the Docs page in Storybook.
- Scroll down. You should now see the Size section with Small and Large button previews, the Disabled section with a dimmed button, and a Props table listing color, size, and disabled with their types and options.
Props table showing too many or too few props?
- The ArgTypes block reads from the argTypes you defined in Button.stories.tsx. If props are missing, check that the story meta includes them in the argTypes object.
- If you see React's internal props (ref, key), Storybook is reading from the raw HTML button interface. Confirm your stories file sets component: Button in the meta object.
Help me fix my ArgTypes props table in Storybook.
Add usage guidelines
Guidelines turn documentation from a reference table into a decision-making tool. They tell other developers when to reach for each variant.
- At the bottom of Button.mdx, below the <ArgTypes of={ButtonStories} /> line, add the following:
## Usage Guidelines
- Always provide meaningful text content or an `aria-label` for icon-only buttons
- Use `danger` color only for irreversible actions
- Prefer `md` size unless space constraints require `sm`
What do these guidelines communicate?
Each guideline addresses a common design system mistake. The first ensures accessibility for icon buttons. The second prevents overuse of the danger color, which would dilute its urgency. The third establishes a default size so teams stay consistent without thinking about it.
- Save Button.mdx.
- Refresh the Docs page and scroll to the bottom. You should see a "Usage Guidelines" heading followed by three bullet points.
Guidelines not rendering as a list?
- MDX requires a blank line between the heading and the first list item. Make sure there is an empty line after ## Usage Guidelines.
- Each list item must start with - (dash followed by a space). No leading whitespace.
Why aren't my MDX markdown lists rendering properly?
✔️ Awesome, I've got everything!
Great. Double check you have saved Button.mdx and all three sections (Variants, Props, Usage Guidelines) are visible on the Docs page.
ⓧ I'd like to double check the full code
Your Button.mdx file should contain the three code chunks you built above, in order:
- Chunk 1: imports, Meta, title, description, Preview canvas, and Color variant section (through the Danger canvas).
- Chunk 2: Size section with Small and Large canvases, Disabled section with its canvas, and Props with ArgTypes.
- Chunk 3: Usage Guidelines heading and three bullet points.
Scroll up and confirm each chunk is present in your file, in that order, with no blank lines between them beyond what was shown in the code blocks.
Before you check the final result, what do you expect to see on the Docs page?
- Refresh the Button Docs page in Storybook one more time.
You should see the full documentation page: a title, two description paragraphs, a live Primary Button preview, Color and Size variant sections with live canvases, a Disabled section, a complete Props table, and three usage guidelines at the bottom.
Your Button now has professional documentation that another developer can read, interact with, and reference. Next up, you will add automated testing to prove the Button actually works correctly and meets accessibility standards.
Add Interaction and Accessibility Testing
Your Button looks right in Storybook and has proper documentation. But you have not proven it actually works correctly.
Interaction tests verify the component responds to user actions. Accessibility tests audit the rendered DOM against WCAG rules. Together they catch bugs before they reach users.
In this step, get ready to:
- Install and configure the accessibility testing addon.
- Verify the interaction test passes in the Storybook panel.
- Catch an accessibility violation and fix it.
Install the accessibility addon
Storybook's @storybook/addon-a11y uses axe-core under the hood to automatically audit your components against WCAG accessibility standards.
- Stop your running Storybook process with Ctrl+C if it is still running.
- Install and register the a11y addon by running this command:
npx storybook@latest add @storybook/addon-a11y
What does this command do?
The storybook add command does two things automatically: it installs the @storybook/addon-a11y package as a dev dependency, and it adds '@storybook/addon-a11y' to the addons array in your .storybook/main.ts file.
The @storybook/addon-interactions addon (which powers the Interactions panel) is already included via @storybook/addon-essentials. No extra install needed for that.
Command not found or permission error?
Make sure you are inside the design-system folder when you run the command. If you see a permission error on macOS, prefix with sudo.
Help me troubleshoot this installation error
Next you need to configure the addon to treat accessibility violations as errors. This ensures violations are flagged prominently.
- Open .storybook/preview.ts in your editor.
- Find the parameters object inside the preview config, and add the a11y configuration so it looks like this:
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
a11y: {
// Treat accessibility violations as test errors
test: 'error',
},
},
};
export default preview;
What does this configuration do?
Setting parameters.a11y.test to 'error' tells the addon to flag accessibility violations as errors rather than passive warnings. Violations appear prominently in the Accessibility panel.
- Save .storybook/preview.ts.
- Start Storybook again by running:
npm run storybook
Storybook opens at localhost:6006. You should see a new Accessibility panel tab at the bottom of the canvas.
Don't see the Accessibility panel?
Confirm that '@storybook/addon-a11y' appears in the addons array in .storybook/main.ts. If the storybook add command did not update the file automatically, add it manually and restart.
Help me debug the missing Accessibility panel
Verify the interaction test passes
Your ClicksHandler story already has a play function that simulates a click and verifies the onClick handler fires. Time to see it in action.
- In the Storybook sidebar, expand Components and click Button.
- Select the Clicks Handler story from the list.
Before you look at the panel below the canvas, predict: do you think the click test passed or failed?
- Click the Interactions tab in the panel at the bottom of the canvas.
You should see a green checkmark and the test steps listed: the simulated click, followed by the assertion that onClick was called once.
How does the interaction test work?
The play function in your ClicksHandler story uses userEvent.click from @storybook/test to simulate a real user clicking the button. Then expect(args.onClick).toHaveBeenCalledOnce() asserts the handler was called exactly once.
The within(canvasElement) call scopes DOM queries to the story's canvas, and findByRole('button') locates the button by its accessible role.
Catch and fix an accessibility violation
Your LowContrast story deliberately uses light gray text on a light gray background. This violates WCAG color contrast requirements. The a11y addon should catch it.
- In the sidebar, select the Low Contrast story.
- Click the Accessibility tab in the bottom panel.
Do you think this story passes accessibility checks? Given the className bg-gray-100 text-gray-300, predict what the addon will report.
You should see a violation flagged for insufficient color contrast. The addon reports which WCAG rule failed and highlights the offending element.
What is color contrast in WCAG?
WCAG requires a minimum contrast ratio between text and its background. For normal text, the ratio must be at least 4.5:1. Light gray text (text-gray-300) on a light gray background (bg-gray-100) falls far below this threshold, making the text nearly invisible to users with low vision.
Now prove you can fix it. You will change the text color to pass the contrast check, then revert it to keep the story as a permanent reference for what violations look like.
- In Button.stories.tsx, find the LowContrast story and locate this line:
className: 'bg-gray-100 text-gray-300',
- Replace text-gray-300 with text-gray-900, so the line now reads:
className: 'bg-gray-100 text-gray-900',
- Save Button.stories.tsx.
- Switch back to the Storybook browser tab. The Low Contrast story reloads automatically.
- Check the Accessibility panel again.
The violation disappears. Dark text on a light background meets the 4.5:1 contrast requirement. Your testing loop works: break a rule, the addon catches it, fix it, the addon confirms.
Now revert the change so the story remains as a demonstration of what violations look like.
- In Button.stories.tsx, change text-gray-900 back to text-gray-300:
className: 'bg-gray-100 text-gray-300',
- Save Button.stories.tsx.
Why keep the failing story?
The LowContrast story serves as a permanent test fixture. It demonstrates what the a11y addon catches and gives future developers an example of what NOT to ship. In a real design system, you might tag it as an expected violation so CI can pass while still showing the issue in the panel.
Confirm the end state: with Storybook running, navigate to the Clicks Handler story and verify the Interactions panel shows a green checkmark. Then navigate to Low Contrast and verify the Accessibility panel flags a color-contrast violation.
Your Button component is now fully tested. You have proven it responds to clicks correctly and you know your accessibility testing pipeline catches real WCAG violations. Next up, you will take this further with the Secret Mission.
Secret mission
Build a Multi-Slot Switch Component
Real design systems have multi-part components. A toggle has a track, a thumb, and coordinated state between them. Can you build a fully accessible Switch component using Base UI's Switch primitive and the tailwind-variants slots API?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The design-system project folder on your Desktop (includes all source code, node_modules, and Storybook configuration).
- The Storybook dev server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you're still actively building more components for your design system.
- Your design-system folder stays on your Desktop, ready for you to add more components.
- If Storybook is still running, you can keep it open to preview changes as you build.
- Node.js and npm remain installed on your machine for future projects.
Pause - I'll come back to this later
Shut down the running Storybook process to free up memory. Your project files stay intact for when you return.
- Switch to the terminal where Storybook is running and press Ctrl+C to stop the dev server.
- Your design-system folder remains on your Desktop. When you return, run npm run storybook from inside the folder to start it again.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- If Storybook is still running, press Ctrl+C in the terminal to stop it.
- Delete the project folder by running this command from your Desktop:
macOS
cd ~/Desktop
rm -rf design-system
Windows
cd ~\Desktop
Remove-Item -Recurse -Force design-system
- Confirm the folder is gone by running ls (macOS) or dir (Windows) and checking that design-system no longer appears.
Nice Work!
Nice Work!
Nice work! You just built a professional-grade design system foundation powered by headless components, variant-driven styling, and automated testing.
You learned how to:
- Built an accessible Button component on Base UI's headless primitive and styled it with tailwind-variants using color variants, size variants, compound variants, boolean modifiers, and default variants.
- Documented the Button with MDX in Storybook using Doc Blocks, and wrote interaction tests with play functions using userEvent and expect from @storybook/test.
- Set up accessibility testing with the a11y addon (powered by axe-core) and caught a real WCAG color-contrast violation, then fixed it to prove the testing loop works.
- Secret Mission: Built a multi-slot Switch component using tailwind-variants slots and Base UI's Switch primitive with state-driven styling via data attributes.
Ready to quiz yourself?