Installation
pnpm dlx shadcn@latest add https://dithercn.dev/r/dither-tabs.jsonUsage
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: 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
Pricing toggle
Two triggers as a monthly and yearly switch, in green. Each panel holds both plans, so switching swaps both prices at once.
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.
Props
DitherTabs
| Prop | Type | Default |
|---|---|---|
| variant Pill lights a dithered surface behind the label. Underline draws a dithered bar under it. | Type: "pill" | "underline" | Default "pill" |
| color Indicator colour: a palette name such as "blue", a CSS variable name, or any CSS colour. | Type: DitherColor | Default "ink" |
| matrix The order cells switch on and off as the indicator moves. | Type: "bayer2" | "bayer4" | "bayer8" | "blue-noise" | Default "bayer4" |
| bloom A soft glow in the indicator's colour that follows the cells. | Type: "off" | "low" | "high" | Default "off" |
| cell Size of one dither cell in CSS pixels. | Type: number | Default 2 |
| value The selected tab, for a controlled component. Pair it with onValueChange. | Type: string | Default – |
| defaultValue The tab selected at first, when uncontrolled. | Type: string | Default – |
| orientation 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. | Type: "horizontal" | "vertical" | Default "horizontal" |
All other Radix Tabs.Root props, such as onValueChange, activationMode and dir, pass through.
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
- 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-visibleon 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"toDitherTabsor wrap it in a RadixDirectionProvider. 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'sdirattribute, 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.