# Dither tabs

Tabs whose active indicator is a dithered pill or underline that glides to the chosen tab, dissolving out of the old one and in at the new one through the Bayer levels.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/dither-tabs.json](https://dithercn.dev/r/dither-tabs.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/dither-tabs.json
```

## Usage [#usage]

```tsx
import {
  DitherTabs,
  DitherTabsContent,
  DitherTabsList,
  DitherTabsTrigger,
} from "@/components/dithercn/dither-tabs";

export function Project() {
  return (
    <DitherTabs defaultValue="overview" color="blue">
      <DitherTabsList aria-label="Project">
        <DitherTabsTrigger value="overview">Overview</DitherTabsTrigger>
        <DitherTabsTrigger value="deploys">Deploys</DitherTabsTrigger>
      </DitherTabsList>
      <DitherTabsContent value="overview">…</DitherTabsContent>
      <DitherTabsContent value="deploys">…</DitherTabsContent>
    </DitherTabs>
  );
}
```

The parts match shadcn's `Tabs` and are built on Radix Tabs, so arrow keys, Home and End, roving focus and the tab, tablist and tabpanel roles work as they do there. `DitherTabs` takes `value` and `onValueChange` for a controlled tab, or `defaultValue` on its own.

The indicator is drawn on one canvas behind the triggers, a canvas pixel per cell. Choosing a tab moves it in three overlapping parts: the old tab's indicator thins out in place, a body glides across with its leading edge ahead of its trailing edge and its dots thinning towards the back, and the new tab's indicator fills in underneath. Every cell switches on or off at its threshold in the matrix, so the move reads as a dissolve rather than a slide. The canvas is only redrawn while the indicator moves, and the trigger positions are measured, so it follows resizes and web fonts that load late.

`pill` is lit like [Dither button](/docs/interface/dither-button): the full colour, a highlight dithered down from the top and a shade dithered up from the bottom, with the label's band left plain. `underline` is a bar whose ends fade through the levels, under a sparse glow that rises towards the label. Inactive tabs show a light dot lattice in the same colour on hover.

Panels change at once, so their text is readable the moment it appears; the indicator carries the motion. A tab chosen with the arrow keys jumps there without a glide, so stepping through tabs never lags behind the keys.

## Examples [#examples]

### Pricing toggle [#pricing-toggle]

Two triggers as a monthly and yearly switch, in green. Each panel holds both plans, so switching swaps both prices at once.

```tsx title="examples/dither-tabs-pricing.tsx"
"use client";

import { DitherButton } from "@/components/dithercn/dither-button";
import {
  DitherTabs,
  DitherTabsContent,
  DitherTabsList,
  DitherTabsTrigger,
} from "@/components/dithercn/dither-tabs";

const PLANS = [
  {
    blurb: "For side projects and small teams.",
    monthly: 15,
    name: "Team",
    yearly: 12,
  },
  {
    blurb: "Audit logs, SSO and priority support.",
    monthly: 40,
    name: "Business",
    yearly: 32,
  },
] as const;

const BILLING = [
  { key: "monthly", note: "billed monthly" },
  { key: "yearly", note: "per month, billed yearly" },
] as const;

export default function DitherTabsPricing() {
  return (
    <DitherTabs
      defaultValue="yearly"
      color="green"
      className="w-full max-w-lg items-center gap-6"
    >
      <DitherTabsList aria-label="Billing period">
        <DitherTabsTrigger value="monthly">Monthly</DitherTabsTrigger>
        <DitherTabsTrigger value="yearly">
          Yearly
          <span className="text-xs font-normal opacity-75">−20%</span>
        </DitherTabsTrigger>
      </DitherTabsList>
      {BILLING.map((billing) => (
        <DitherTabsContent
          key={billing.key}
          value={billing.key}
          className="grid w-full gap-3 sm:grid-cols-2"
        >
          {PLANS.map((plan) => (
            <div
              key={plan.name}
              className="bg-background flex flex-col gap-4 rounded-xl border p-5"
            >
              <div className="flex flex-col gap-1">
                <span className="text-sm font-medium">{plan.name}</span>
                <span className="text-muted-foreground text-sm">
                  {plan.blurb}
                </span>
              </div>
              <div className="flex items-baseline gap-1.5">
                <span className="text-3xl font-semibold tracking-tight tabular-nums">
                  ${plan[billing.key]}
                </span>
                <span className="text-muted-foreground text-xs">
                  {billing.note}
                </span>
              </div>
              <DitherButton
                color="green"
                variant={plan.name === "Business" ? "filled" : "outline"}
                size="sm"
              >
                Start a trial
              </DitherButton>
            </div>
          ))}
        </DitherTabsContent>
      ))}
    </DitherTabs>
  );
}
```

### Dashboard sections [#dashboard-sections]

The `underline` variant across the top of a card, switching between three metrics. The list stretches to the card's width and draws a hairline under the triggers; the bar sits on it.

```tsx title="examples/dither-tabs-dashboard.tsx"
"use client";

import {
  DitherTabs,
  DitherTabsContent,
  DitherTabsList,
  DitherTabsTrigger,
} from "@/components/dithercn/dither-tabs";
import { Sparkline } from "@/components/dithercn/sparkline";

const SECTIONS = [
  {
    data: [612, 640, 598, 705, 760, 731, 802, 846, 829, 901, 958, 1012],
    delta: "+18% on last week",
    label: "Traffic",
    unit: "requests per minute",
    value: "1,012",
  },
  {
    data: [14, 11, 12, 9, 22, 31, 18, 12, 9, 8, 7, 6],
    delta: "Back to normal after 14:20",
    label: "Errors",
    unit: "5xx per hour",
    value: "6",
  },
  {
    data: [182, 176, 190, 185, 171, 168, 175, 162, 158, 161, 155, 149],
    delta: "−33 ms since the cache change",
    label: "Latency",
    unit: "p95 in ms",
    value: "149",
  },
] as const;

export default function DitherTabsDashboard() {
  return (
    <div className="bg-background w-full max-w-xl rounded-2xl border px-5 pt-3 pb-5 [--dither-paper:var(--background)]">
      <DitherTabs defaultValue="Traffic" variant="underline" color="blue">
        <DitherTabsList aria-label="Metric" className="w-full">
          {SECTIONS.map((section) => (
            <DitherTabsTrigger key={section.label} value={section.label}>
              {section.label}
            </DitherTabsTrigger>
          ))}
        </DitherTabsList>
        {SECTIONS.map((section) => (
          <DitherTabsContent
            key={section.label}
            value={section.label}
            className="flex flex-col gap-4"
          >
            <div className="flex flex-col gap-0.5">
              <span className="text-muted-foreground text-xs">
                {section.unit}
              </span>
              <span className="text-3xl font-semibold tracking-tight">
                {section.value}
              </span>
              <span className="text-muted-foreground text-sm">
                {section.delta}
              </span>
            </div>
            <Sparkline
              data={section.data}
              label={`${section.label}, last 12 hours`}
              color="blue"
              height={64}
            />
          </DitherTabsContent>
        ))}
      </DitherTabs>
    </div>
  );
}
```

## Props [#props]

### DitherTabs [#dithertabs]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"pill" \| "underline"` | `"pill"` | Pill lights a dithered surface behind the label. Underline draws a dithered bar under it. |
| `color` | `DitherColor` | `"ink"` | Indicator colour: a palette name such as "blue", a CSS variable name, or any CSS colour. |
| `matrix` | `"bayer2" \| "bayer4" \| "bayer8" \| "blue-noise"` | `"bayer4"` | The order cells switch on and off as the indicator moves. |
| `bloom` | `"off" \| "low" \| "high"` | `"off"` | A soft glow in the indicator's colour that follows the cells. |
| `cell` | `number` | `2` | Size of one dither cell in CSS pixels. |
| `value` | `string` | `–` | The selected tab, for a controlled component. Pair it with onValueChange. |
| `defaultValue` | `string` | `–` | The tab selected at first, when uncontrolled. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Vertical stacks the triggers, moves the indicator up and down, and puts the underline on the start edge: the left, or the right in a right-to-left context. |

All other Radix `Tabs.Root` props, such as `onValueChange`, `activationMode` and `dir`, pass through.

### DitherTabsList, DitherTabsTrigger, DitherTabsContent [#dithertabslist-dithertabstrigger-dithertabscontent]

These take the props of Radix `Tabs.List`, `Tabs.Trigger` and `Tabs.Content`. Give the list an `aria-label`, and every trigger a `value` that matches one panel. A trigger can hold an icon or a short note next to its label.

## Notes [#notes]

* The indicator, the hover dots and the glow are hidden from assistive technology. Screen readers hear the Radix roles and the selected state.
* Focus shows a 2 pixel outline with `:focus-visible` on triggers and panels.
* With reduced motion, the indicator jumps to the new tab and labels change colour without a fade.
* Before the canvas first draws, and without JavaScript, the active trigger gets a flat version of the indicator in CSS, so the selection never goes missing.
* Right to left works through Radix: pass `dir="rtl"` to `DitherTabs` or wrap it in a Radix `DirectionProvider`. Arrow keys follow the direction, the indicator follows the measured triggers, and the vertical underline, its hairline, the hover dots and the label indent move to the right. Radix does not read the page's `dir` attribute, so an RTL page needs one of the two.
* The list does not wrap. For many tabs on a narrow screen, give it `overflow-x-auto`: the canvas covers the whole scroll width, in either direction.