Skip to main content
NextStarter
Menu

Design System

UI Components

Every component in src/components/ui, along with the colour and type foundations they are built from. The previews are the real components, render here exactly as they will in your app, and the accessibility scan that runs on every push scans this page too.

Everything below works with both the light and dark themes. Switch the theme with the toggle in the header to preview.

Foundations

Color

Every colour is defined in src/app/globals.css. Stone, a neutral grey, is used for most of the interface, and orange is kept for the few things that should stand out. The components in src/components/ui don’t use those shades directly. They use named tokens such as card and border, which describe what a colour is for, so changing a token restyles every component that uses it.

Stone

The neutral ramp. Page backgrounds, borders, and every level of text.

  • stone-50

    #fafaf9

  • stone-100

    #f5f5f4

  • stone-200

    #e7e5e4

  • stone-300

    #d6d3d1

  • stone-400

    #a8a29e

  • stone-500

    #78716c

  • stone-600

    #57534e

  • stone-700

    #44403c

  • stone-800

    #292524

  • stone-900

    #1c1917

  • stone-950

    #0c0a09

Code
/* src/app/globals.css */
@theme {
  --color-stone-600: #57534e;
  --color-orange-700: #c2410c;
}

{/* Anywhere in a component */}
<p className="text-stone-600 dark:text-stone-400">Body copy</p>
<a className="hover:text-orange-700 dark:hover:text-orange-400">A link</a>

Orange

The accent. Links, hover states, and the active item in a navigation.

  • orange-50

    #fff7ed

  • orange-100

    #ffedd5

  • orange-200

    #fed7aa

  • orange-300

    #fdba74

  • orange-400

    #fb923c

  • orange-500

    #f97316

  • orange-600

    #ea580c

  • orange-700

    #c2410c

  • orange-800

    #9a3412

  • orange-900

    #7c2d12

  • orange-950

    #431407

Code
/* src/app/globals.css */
@theme {
  --color-stone-600: #57534e;
  --color-orange-700: #c2410c;
}

{/* Anywhere in a component */}
<p className="text-stone-600 dark:text-stone-400">Body copy</p>
<a className="hover:text-orange-700 dark:hover:text-orange-400">A link</a>

Semantic tokens

Each token has one value for light mode and another for dark mode, so these swatches change when you switch themes. A component built with these tokens works in both modes without any dark: classes of its own.

  • background

  • foreground

  • card

  • popover

  • primary

  • secondary

  • muted

  • accent

  • destructive

  • border

  • input

  • ring

Code
/* src/app/globals.css - one definition per theme */
@theme {
  --color-card: #ffffff;
  --color-card-foreground: hsl(222.2, 84%, 4.9%);
}

.dark {
  --color-card: hsl(222.2, 84%, 4.9%);
  --color-card-foreground: hsl(210, 40%, 98%);
}

{/* The component needs no dark: variant of its own */}
<div className="bg-card text-card-foreground">…</div>

Chart

Five hues that stay distinguishable beside one another, for data visualisation.

  • chart-1

  • chart-2

  • chart-3

  • chart-4

  • chart-5

Code
<span className="bg-chart-1" />

Foundations

Typography

Headings use a serif font, body text uses a sans-serif, and code uses a monospace font. Every page on the site uses the text sizes below. Stick to them and a new page will look like part of the same site.

Type scale

Seven steps, largest first. Headings scale up at the md breakpoint; body copy does not.

  • Ship accessible Next.js apps

    h1 - one per page

    font-serif text-3xl font-bold md:text-4xl

  • What you get

    h2 - section heading

    font-serif text-2xl font-bold md:text-3xl

  • Tested on every push

    h3 - subsection heading

    text-lg font-medium

  • The introduction that follows a page title.

    Lead paragraph

    text-lg leading-relaxed

  • The default: 16px, relaxed leading, stone-600 on light.

    Body

    leading-relaxed

  • Updated 17 September 2026

    Small - captions, meta

    text-sm

  • npx create-next-app

    Mono - code and values

    font-mono text-sm

Code
<h1 className="font-serif text-3xl font-bold md:text-4xl">Page title</h1>
<h2 className="font-serif text-2xl font-bold md:text-3xl">Section</h2>
<p className="text-lg leading-relaxed text-stone-600 dark:text-stone-300">
  A lead paragraph.
</p>
<p className="leading-relaxed text-stone-600 dark:text-stone-400">Body copy.</p>

Font families

Geist Sans and Geist Mono are web fonts, loaded by next/font in the root layout. The serif uses fonts already installed on the visitor's device, so the browser has nothing extra to download, though it can look slightly different from one device to another.

  • Ag 123

    Geist Sans

    --font-geist-sans

  • Ag 123

    Geist Mono

    --font-geist-mono

  • Ag 123

    Serif display

    font-serif

Code
// src/app/layout.tsx
const geistSans = Geist({
  subsets: ["latin"],
  variable: "--font-geist-sans",
  weight: ["400", "700"],
});

<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>

{/* Reach for a family explicitly where it matters */}
<code className="font-mono text-sm">npm run dev</code>
<span className="font-(family-name:var(--font-geist-sans))">Geist Sans</span>

Actions

Button

One component, class-variance-authority for the variants, and a Slot from Radix behind asChild so a button’s styling can be handed to a link. Every one below is focusable. Tab through them to see the focus ring the whole system shares.

Variants

Six levels of emphasis. One default per view is the rule of thumb; destructive is reserved for actions that lose data.

  • default
  • secondary
  • outline
  • ghost
  • link
  • destructive
Code
import { Button } from "@/components/ui/button";

<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="destructive">Destructive</Button>

Sizes

Four text sizes. The height is fixed per size and the padding tightens when the button contains an icon.

  • xs
  • sm
  • default
  • lg
Code
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>

With icons

Square sizes for icon-only buttons, and icons alongside a label. Icons inside a button are sized automatically.

  • icon-xs
  • icon-sm
  • icon
  • icon-lg
  • leading icon
  • trailing icon
Code
{/* Icon-only: the name lives in a visually hidden span */}
<Button size="icon" variant="outline">
  <Star aria-hidden="true" />
  <span className="sr-only">Favourite</span>
</Button>

{/* With a label, the icon is decoration and is hidden from assistive tech */}
<Button>
  <Download aria-hidden="true" />
  Download
</Button>

States and asChild

Disabled drops to half opacity and stops pointer events. aria-invalid tints the ring, which is how a button that failed validation reports it.

  • disabled
  • disabled outline
  • aria-invalid
  • Home pageasChild - renders an anchor
Code
<Button disabled>Disabled</Button>
<Button aria-invalid="true" variant="outline">Invalid</Button>

{/* asChild hands the styling to the child, so a link stays a link */}
<Button asChild>
  <Link href="/">
    Home page
    <ArrowRight aria-hidden="true" />
  </Link>
</Button>

Data display

Card

Seven parts that compose rather than one component with a dozen props. You can use only the parts you need and using a card with nothing but CardContent inside it is a perfectly ordinary use of it. Colours come from the card tokens, so cards invert with the theme on their own.

Anatomy

Header, content, and footer. The footer takes its top border from a plain border-t; the padding follows automatically.

Accessibility, verified
Axe-core runs over every page on every push.

Light and dark are both scanned, because a contrast failure that only exists in one theme is still a failure.

Code
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

<Card>
  <CardHeader>
    <CardTitle>Accessibility, verified</CardTitle>
    <CardDescription>Axe-core runs over every page on every push.</CardDescription>
  </CardHeader>
  <CardContent>
    <p>Light and dark are both scanned…</p>
  </CardContent>
  <CardFooter className="border-t">
    <Button size="sm" variant="outline">Read the report</Button>
  </CardFooter>
</Card>

With an action

CardAction places a control in the top-right of the header, and the header switches to a two-column grid to make room for it.

Deployment
Last shipped 4 minutes ago

Production is running commit 04acb83 on the main branch.

Code
{/* CardAction moves itself into the header's second column */}
<CardHeader className="border-b">
  <CardTitle>Deployment</CardTitle>
  <CardDescription>Last shipped 4 minutes ago</CardDescription>
  <CardAction>
    <Button size="icon-sm" variant="ghost">
      <MoreHorizontal aria-hidden="true" />
      <span className="sr-only">Deployment options</span>
    </Button>
  </CardAction>
</CardHeader>

As a plain surface

Dropping the header and tightening the padding turns the same component into a bare surface.

  • 98

    Lighthouse, mobile

  • 0

    Axe violations

  • 112 kB

    First load JS

Code
{/* No header or footer */}
<Card className="gap-2 py-5">
  <CardContent>
    <p className="text-2xl font-bold">98</p>
    <p className="text-sm text-muted-foreground">Lighthouse, mobile</p>
  </CardContent>
</Card>

Overlays

Tooltip

With Radix underneath, a tooltip opens on hover and on keyboard focus, closes on Esc, and is associated with its trigger for assistive technology. It is a hint about a control, never the control’s only label. Tab through the examples below rather than hovering them to see the difference.

Sides

A side is a preference, not a guarantee: Radix flips a tooltip that would fall outside the viewport.

  • side="top"
  • side="right"
  • side="bottom"
  • side="left"
Code
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

<TooltipProvider>
  <Tooltip>
    <TooltipTrigger asChild>
      <Button variant="outline">Hover me</Button>
    </TooltipTrigger>
    <TooltipContent side="top">Above the trigger</TooltipContent>
  </Tooltip>
</TooltipProvider>

On an icon button

Icon buttons are where tooltips are most useful, and where they most often go wrong. Give the button its own hidden label for screen readers, and use the tooltip only for extra detail.

  • icon trigger
  • text trigger
Code
{/* Label the button for screen readers; the tooltip doesn't count as a label */}
<Tooltip>
  <TooltipTrigger asChild>
    <Button size="icon-sm" variant="ghost">
      <Info aria-hidden="true" />
      <span className="sr-only">About the build</span>
    </Button>
  </TooltipTrigger>
  <TooltipContent>Built from the main branch</TooltipContent>
</Tooltip>