# Dither switch

A toggle switch whose track fills with dither behind the thumb as it turns on, stepping through the Bayer levels, and drains the same way when it turns off.

> 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-switch.json](https://dithercn.dev/r/dither-switch.json), under `files[].content`.

## Installation [#installation]

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

## Usage [#usage]

```tsx
import { DitherSwitch } from "@/components/dithercn/dither-switch";

export function AutoDeploy() {
  return (
    <div className="flex items-center gap-3">
      <DitherSwitch id="auto-deploy" color="blue" defaultChecked />
      <label htmlFor="auto-deploy" className="text-sm">
        Auto-deploy
      </label>
    </div>
  );
}
```

`DitherSwitch` is the Radix `Switch`: a `<button role="switch">` that you control with `checked` and `onCheckedChange`, or leave uncontrolled with `defaultChecked`. Space and Enter toggle it. Give it a name with a `<label htmlFor>` or `aria-label`.

Off, the track is a faint grey tint with a sparse grey texture inside a hairline. Turning it on fills the track from the left in five layers: four copies of the colour masked to denser and denser Bayer levels, then the full lit surface, the colour with a highlight dithered down from the top and a shade dithered up from the bottom, as on [Dither button](/docs/interface/dither-button). Each layer starts a beat after the one below it, so sparse cells run just behind the thumb and the dense ones fill in after. Turning it off runs the wave backwards: the solid surface pulls back with the thumb and the sparse levels drain after it.

The thumb is a light knob lit the same way, over a dithered drop shadow. Hover brightens the knob and the track, and pressing shades the knob from the top.

## Examples [#examples]

### Settings with a form [#settings-with-a-form]

A list of labelled switches with descriptions inside a `<form>`. Each switch has a `name`, so it submits as a checkbox would: `on` when checked, absent when not. The first row is `disabled`; like a disabled checkbox, it is left out of what the form sends. Each description is tied to its switch with `aria-describedby`. Toggle a few and press Save.

```tsx title="examples/dither-switch-settings.tsx"
"use client";

import { useId, useState } from "react";

import { DitherButton } from "@/components/dithercn/dither-button";
import { DitherSwitch } from "@/components/dithercn/dither-switch";

const SETTINGS = [
  {
    defaultChecked: true,
    description: "Failed builds and expiring domains.",
    disabled: true,
    label: "Security alerts",
    name: "security",
  },
  {
    defaultChecked: true,
    description: "When a deployment you started goes live.",
    disabled: false,
    label: "Deployments",
    name: "deployments",
  },
  {
    defaultChecked: false,
    description: "Mentions and replies on preview links.",
    disabled: false,
    label: "Comments",
    name: "comments",
  },
  {
    defaultChecked: false,
    description: "One email every Monday with last week's usage.",
    disabled: false,
    label: "Weekly digest",
    name: "digest",
  },
] as const;

export default function DitherSwitchSettings() {
  const id = useId();
  const [saved, setSaved] = useState<string | null>(null);

  return (
    <form
      className="bg-background ring-border flex w-full max-w-md flex-col rounded-xl ring-1"
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        const on = SETTINGS.filter((setting) => data.has(setting.name));
        setSaved(on.map((setting) => setting.label).join(", ") || "nothing");
      }}
    >
      <div className="flex flex-col gap-1 border-b px-5 py-4">
        <span className="font-medium">Email notifications</span>
        <span className="text-muted-foreground text-sm">
          Choose what lands in your inbox.
        </span>
      </div>
      <ul className="flex flex-col">
        {SETTINGS.map((setting) => (
          <li
            key={setting.name}
            className="flex items-center justify-between gap-6 border-b px-5 py-3.5"
          >
            <div className="flex min-w-0 flex-col gap-0.5">
              <label
                htmlFor={`${id}-${setting.name}`}
                className="text-sm font-medium"
              >
                {setting.label}
              </label>
              <span
                id={`${id}-${setting.name}-description`}
                className="text-muted-foreground text-sm text-pretty"
              >
                {setting.description}
              </span>
            </div>
            <DitherSwitch
              id={`${id}-${setting.name}`}
              aria-describedby={`${id}-${setting.name}-description`}
              name={setting.name}
              defaultChecked={setting.defaultChecked}
              disabled={setting.disabled}
              color="green"
            />
          </li>
        ))}
      </ul>
      <div className="flex items-center justify-between gap-4 px-5 py-4">
        <span
          aria-live="polite"
          className="text-muted-foreground min-w-0 truncate text-sm"
        >
          {saved === null ? null : `Saved: ${saved}`}
        </span>
        <DitherButton type="submit" size="sm" color="green">
          Save
        </DitherButton>
      </div>
    </form>
  );
}
```

### Fills, colours and sizes [#fills-colours-and-sizes]

Every fill in four colours, then the three sizes. `fill` sets the texture of the shade when on and of the track when off: `gradient` is a Bayer ramp up from the bottom, `dotted` a staggered dot lattice, `hatched` rising diagonals, and `solid` a thin dithered rim. Toggle any of them to watch the wave.

```tsx title="examples/dither-switch-palette.tsx"
import { DitherSwitch } from "@/components/dithercn/dither-switch";
import { FILL_VARIANTS } from "@/lib/dither";
import type { DitherColorName } from "@/lib/dither";

const COLORS: readonly DitherColorName[] = ["blue", "green", "orange", "pink"];
const SIZES = ["sm", "md", "lg"] as const;

export default function DitherSwitchPalette() {
  return (
    <div className="flex flex-col items-center gap-6">
      <div className="grid grid-cols-[auto_repeat(4,auto)] items-center gap-x-4 gap-y-3 sm:gap-x-5">
        {FILL_VARIANTS.map((fill) => (
          <div key={fill} className="contents">
            <span className="text-muted-foreground font-mono text-xs">
              {fill}
            </span>
            {COLORS.map((color) => (
              <DitherSwitch
                key={color}
                aria-label={`${color} ${fill}`}
                defaultChecked
                fill={fill}
                color={color}
              />
            ))}
          </div>
        ))}
      </div>
      <div className="flex items-end gap-5">
        {SIZES.map((size) => (
          <div key={size} className="flex flex-col items-center gap-2">
            <DitherSwitch
              aria-label={`Size ${size}`}
              defaultChecked
              size={size}
              color="purple"
            />
            <span className="text-muted-foreground font-mono text-xs">
              {size}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `checked` | `boolean` |  | Whether it is on, when you control it. Pair with onCheckedChange. |
| `defaultChecked` | `boolean` | `false` | Whether it starts on, when it is uncontrolled. |
| `onCheckedChange` | `(checked: boolean) => void` |  | Called with the new state on every toggle. |
| `color` | `DitherColor` | `"ink"` | Colour of the ink when on: a palette name such as "blue" or "green", a CSS variable name, or any CSS colour. |
| `fill` | `"gradient" \| "dotted" \| "hatched" \| "solid"` | `"gradient"` | Texture of the shade when on and of the track when off: a Bayer ramp, a dot lattice, diagonals, or a thin rim. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Tracks of 34 by 20, 42 by 24 and 54 by 30 pixels, rounded to whole cells. |
| `bloom` | `"off" \| "low" \| "high"` | `"off"` | A soft glow in the ink's colour that follows the cells when on. |
| `cell` | `number` | `2` | Size of one dither cell in CSS pixels. |
| `disabled` | `boolean` | `false` | Ignores clicks and keys and drops to 45% opacity. |
| `name` | `string` |  | Submits the switch with its form. Use value to send something other than on, and required to make it mandatory. |

All other props pass through to the Radix `Switch.Root` button.

## Notes [#notes]

* Focus shows a 2 pixel outline with `:focus-visible` and the hover highlight, so keyboard users see the same step as pointer users.
* The clip of every layer and the thumb move on a staircase easing, one step per cell that follows the ease-out curve, so the cells never sit between pixels mid-move. Browsers without CSS `linear()` easing use even steps instead.
* The hit area reaches a few pixels past the track, so the small size is still easy to tap.
* With reduced motion, the switch changes state at once: no wave, no slide, no fades.
* The surface is plain CSS masks and clips in one shared `<style>` tag. There is no canvas and no WebGL, so a settings page with dozens of switches stays cheap.