Skip to content

Design System — Component Authoring

FieldValue
TypeSkill Resource
Source~/.copilot/skills/frontend/references/ds-component.md
DescriptionNot specified

Source Content

Design System — Component Authoring

Build and document React components for @dmwd-io/design-system. Absorbed from the former ds-component skill. Always pair with references/storybook.md (story setup, argTypes) and the design-principles skill (icon grid ADR-009, border ADR-008 — those live outside this skill, in design-principles).

File structure

Every component lives in src/components/ui/ (primitives) or src/components/patterns/<domain>/ (composed patterns). Three files minimum:

src/components/ui/
component-name.tsx # component + types + CVA — no test code
component-name.stories.tsx # Storybook stories — required, same directory
component-name.test.tsx # Vitest unit tests — when behaviour warrants it

Export the component, the Props type, and the CVA helper from the .tsx file. Register the export in src/components/ui/index.ts.

Props interface

Every exported *Props interface follows this shape exactly:

import type { HTMLAttributes, ReactNode } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const componentNameVariants = cva(
// base classes — semantic tokens only, no raw hex, no Tailwind palette numbers
"inline-flex items-center rounded-lg font-body text-sm transition-control duration-fast ease-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground shadow-sm hover:bg-surface-strong",
secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
outline: "border border-border bg-background text-foreground hover:bg-secondary",
ghost: "text-foreground hover:bg-secondary",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-9 px-3.5",
lg: "h-10 px-4",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
/** Props for the {@link ComponentName} component. Extends native `<button>` attributes and CVA `componentNameVariants`. */
export interface ComponentNameProps
extends HTMLAttributes<HTMLElement>, // or ButtonHTMLAttributes, InputHTMLAttributes, etc.
VariantProps<typeof componentNameVariants> {
/** Short prose sentence. Defaults to `"primary"`. */
variant?: "primary" | "secondary";
/** Icon rendered before the label. Wrap in aria-hidden="true" inside the component. */
leadingIcon?: ReactNode;
/** Shows a loading spinner and disables interaction. */
loading?: boolean;
}

JSDoc rules (non-negotiable):

  • Every prop gets a short JSDoc sentence — one line, plain English.
  • Write defaults as prose: Defaults to \”md”`.— never@default`.
  • asChild, className, children, disabled, ref are inherited from the HTML type — do not redocument them.

Component function

/**
* Short description of what this component is and does.
*
* Use for [primary use case]. Renders as a `<button>` — interactive.
* @param props - Props for the component.
* @returns The rendered component.
*/
export function ComponentName({
className,
variant,
size,
leadingIcon,
loading = false,
children,
...props
}: ComponentNameProps) {
return (
<button
className={cn(componentNameVariants({ variant, size }), className)}
disabled={loading}
aria-busy={loading || undefined}
{...props}
>
{leadingIcon ? (
<span className="mt-0.5 shrink-0" aria-hidden="true">{leadingIcon}</span>
) : null}
{children}
</button>
);
}
export { componentNameVariants };

Function rules:

  • Always export function — never const Component = () => or arrow functions for components.
  • Destructure inside the function body, not in the parameter list signature.
  • Use cn() for every className merge — never template literals or string concatenation.
  • Use CVA for conditional class logic — never stack className ternaries inline.
  • ...props spread goes on the root element; never spread on an inner child.

Icon layout (ADR-009 — non-negotiable)

Any component with a leading or trailing icon uses a CSS grid, never flex. Content never stacks below the icon.

// Two-column: icon | content
<div className="grid grid-cols-[auto_1fr] items-start gap-3">
<ComponentIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div>
<p className="text-sm font-medium text-foreground">Title</p>
<p className="text-xs text-muted-foreground">Supporting text</p>
</div>
</div>
// Three-column: icon | content | action
<div className="grid grid-cols-[auto_1fr_auto] items-start gap-3">
<ComponentIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div>…content…</div>
<Button size="sm" variant="ghost">Action</Button>
</div>

Load the design-principles skill before building any icon-bearing component.

Token rules

RuleDoDon’t
Colorstext-foreground, bg-card, border-border, text-primarytext-gray-500, bg-blue-100, #3b82f6
Opacity modifiersbg-muted/40bg-gray-100/40
Radiusrounded-xl (cards) · rounded-lg (buttons/inputs) · rounded-md (badges)raw rounded-[10px]
Typographyfont-body, ui-type-* tokensweb fonts, font-inter, font-sans
Bordersone border border-border per local stacknested border border-border on child of bordered parent

Accessibility checklist

Every component ships these:

  • Semantic HTML element — <button> for action, <a href> for navigation, <input> for field. Never <div onClick>.
  • focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 on every interactive element. Never outline-none without a ring replacement.
  • Decorative icons: aria-hidden="true". Icon-only buttons: aria-label or title on the button.
  • Minimum touch target 44×44px for mobile interactions.
  • State conveyed by more than color alone (pair color change with icon or text).
  • disabled:pointer-events-none disabled:opacity-50 via CVA base classes.
  • aria-busy on loading states.

Storybook stories (required)

Every component needs a .stories.tsx file in the same directory. Use @storybook/react-vite, createComponentDocs, and satisfies Meta<>.

import type { Meta, StoryObj } from "@storybook/react-vite";
import { createComponentDocs } from "@/lib/storybook-docs";
import { ComponentName } from "./component-name";
const meta = {
title: "Foundations/ComponentName", // check ADR-016 for the correct root section
component: ComponentName,
tags: ["autodocs"],
parameters: createComponentDocs({
componentName: "ComponentName",
summary: "One sentence: what this component IS and its job.",
whenToUse: "Concrete scenario when an engineer should reach for this.",
whenNotToUse: "What to use instead and why.",
accessibility: "Screen reader, keyboard, and contrast notes specific to this component.",
dummyData: "Realistic example values for controls (e.g. 'Short labels: Done, In progress, Pending').",
controls: "Which controls are wired and what they demonstrate.",
}),
args: {
// sensible defaults shown in every story
},
argTypes: {
// see references/storybook.md for argType patterns
},
} satisfies Meta<typeof ComponentName>;
export default meta;
type Story = StoryObj<typeof meta>;

Nine UI states — cover all that apply:

StateStory name pattern
Defaultexport const Default: Story = { args: {} }
Variant sweepOne named export per variant: Primary, Secondary, Outline
Size sweepSmall, Medium, Large
With iconWithLeadingIcon, WithTrailingIcon
Disabledexport const Disabled: Story = { args: { disabled: true } }
Loadingexport const Loading: Story = { args: { loading: true } }
Error / destructiveexport const Destructive: Story = { args: { variant: "destructive" } }
Empty / no contentStory with no children / empty list
SuccessComplete, Active, or success variant story

Pair with references/storybook.md for full story setup rules and argType patterns.

Pre-commit gates

Always run before declaring a component done:

Terminal window
pnpm typecheck # tsc --noEmit — zero errors required
pnpm vitest run --project unit # unit tests green
pnpm build-storybook # for story or visual changes
scripts/lint_ds_component.sh src/components/ui/<component>.tsx # house-rule check below

scripts/lint_ds_component.sh <component-file.tsx> checks the house rules from this file: every prop has JSDoc, CVA variants are used for style branching, cn() composes classes, a sibling .stories.tsx exists, and the named-export shape is correct.

What NOT to do

  • No style={{}} for visual mechanics — all visual properties belong in CVA or cn().
  • No multi-line conditional className ternaries — introduce cva() instead.
  • No @default in JSDoc — write defaults as prose.
  • No const Component = (): JSX.Element => — use export function.
  • No raw Tailwind palette colors (gray-500, blue-100) — semantic tokens only.
  • No web fonts (font-inter, font-sans, font-serif mapping to a custom face) — system UI stack only.
  • No flex-col on icon rows — CSS grid only (ADR-009).
  • No second border border-border inside a parent that already has one (ADR-008).
  • No outline-none without a focus-visible:ring replacement.
  • No aria-hidden on interactive elements. </content>