Display

Density Toggle

A compact / comfortable / spacious switch that publishes seven --density-* CSS variables to a whole subtree, with a 24px target floor the compact step cannot cross.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Rows2, Rows3, Rows4 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"
import { useLocalStorage } from "@/registry/hooks/use-local-storage"

export type Density = "compact" | "comfortable" | "spacious"

export const DENSITIES: readonly Density[] = ["compact", "comfortable", "spacious"] as const

/**
 * WCAG 2.2 SC 2.5.8 "Target Size (Minimum)", level AA: an interactive target must be at

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/density-toggle.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Density Toggle" — a three-step display-density
switch whose entire output is a set of CSS custom properties, plus the control that
picks the step. Two exported components, one context hook.

Contract
- type Density = "compact" | "comfortable" | "spacious"; DENSITIES is the ordered tuple.
- DensityStep = { rowMinHeight, paddingX, paddingY, gap, controlMinHeight, fontSize,
  lineHeight } — authored as plain numbers in CSS px (lineHeight unitless) at a 16px
  root. defaultDensityScale ships all three steps:
    compact      32 /  8 / 4 /  4 / 24 / 13 / 1.35
    comfortable  40 / 12 / 8 /  8 / 32 / 14 / 1.5
    spacious     52 / 16 / 12 / 12 / 40 / 15 / 1.7
- <DensityScope> — forwardRef, renders `as` (default "div"), spreads native props.
  Props: value?, defaultValue = "comfortable", onValueChange?, storageKey?,
  scale? (Partial<Record<Density, Partial<DensityStep>>>), as?.
  It owns the value, publishes the variables on its own element, and provides
  { density, setDensity, step } through context.
- <DensityToggle> — forwardRef, the control. Props: value?, defaultValue?,
  onValueChange?, label = "Density", options? (relabel/reorder/re-icon for i18n),
  iconOnly?.
- useDensity() reads { density, setDensity, step } from the nearest scope and throws
  outside one. `step` is the resolved numbers, for consumers that need a row height in
  JS (a virtualizer, a canvas) rather than in CSS.

Behavior
- VARIABLES, NOT PROPS. This is the whole reason the component exists: the scope emits
  --density-row-h, --density-pad-x, --density-pad-y, --density-gap,
  --density-control-h, --density-font-size, --density-line-height, and descendants
  follow by naming them. Never thread a `density` prop down a tree. font-size and
  line-height are ALSO set on the scope element, since they inherit for free.
- THE TARGET FLOOR IS A CONTRACT, NOT A DEFAULT. --density-control-h is emitted as
  `max(<step>px, var(--density-touch-floor, 24px))`, and one hoisted stylesheet sets
  `@media (pointer: coarse){[data-density-scope]{--density-touch-floor:44px}}`. So the
  compact step is 24px on a mouse (WCAG 2.2 SC 2.5.8 AA) and 44px on touch, and the
  clamp in resolveDensityStep independently refuses any `scale` value below 24. Do NOT
  write `--density-control-h: max(var(--density-control-h), 44px)` — a custom property
  referencing itself is a cyclic dependency and computes to unset, i.e. no floor at all.
- Every `scale` field is clamped: non-finite (NaN / Infinity) and negative values fall
  back to the shipped number, lengths floor at 0, lineHeight at 1, controlMinHeight
  at 24.
- Ownership is decided once and never mixed. DensityScope: `value` -> the parent owns
  it; else a non-empty `storageKey` -> localStorage owns it; else internal state.
  DensityToggle: `value` -> the parent; else an enclosing scope; else internal state.
  onValueChange fires in every mode.
- PERSISTENCE MUST SURVIVE SSR. Back storageKey with a useSyncExternalStore-based
  localStorage hook whose getServerSnapshot returns the default: the server and the
  first client frame render the same markup, and the stored step is adopted after
  hydration. Reading localStorage during render is the bug this avoids. A hand-edited
  or stale stored value that is not one of the three steps normalizes to the default
  instead of emitting junk CSS.
- ARIA: the three steps are mutually exclusive and exactly one is always active, so the
  control is role="radiogroup" with role="radio" + aria-checked children — NOT an
  aria-pressed toolbar, which would tell a screen reader that several could be on at
  once. One tab stop for the group (tabIndex 0 on the checked radio, -1 on the rest);
  arrow keys move AND select, because in a radiogroup focus follows selection:
  Right/Down = next, Left/Up = previous, Home/End = ends, wrapping at both ends, and
  modifier + arrow is left to the OS.
- The control's own size never follows the density it sets. A control that shrinks as
  you compact it is a moving pointer target and would be the smallest thing on screen
  at the compact step.

Rendering & styling
- Semantic tokens only: bg-muted/60, bg-background, text-foreground,
  text-muted-foreground, border, ring, shadow-sm. No hex, no rgb(), no oklch().
  The component deliberately sets no colour and no radius of its own — colour and
  radius are a theming concern, not a density one.
- cn() merges the consumer className on both components; the scope spreads native props
  and carries data-density="<step>" plus data-density-scope for styling hooks and tests.
- The only animation is the toggle's transition-colors, killed under
  motion-reduce:transition-none. Changing density is instant on purpose — animating
  every descendant's height would need the descendants' cooperation and would jank a
  long table; a consumer who wants it adds a transition on their own rows.
- focus-visible:ring-2 ring-ring ring-offset-1 on each radio.

Customization levers
- The scale: pass `scale={{ compact: { rowMinHeight: 28, paddingY: 2 } }}` to retune one
  step, or all three, to your own spacing system. Only the fields you name change; the
  24px control floor still applies.
- More or fewer steps: the API is a value union — a two-step "compact / default" or a
  four-step scale means extending Density, defaultDensityScale and `options` together.
- More variables: add to DensityStep + densityStepToVars if your surface needs, say,
  --density-icon-size or --density-avatar-size. Keep them layout-only; colour and radius
  belong to the theme, not the density.
- Control shape: `iconOnly` for a tight toolbar, `options` to relabel for i18n or to
  swap the Rows4 / Rows3 / Rows2 icons; the group is an inline-flex pill, so it drops
  into a toolbar unchanged.
- Placement: put the scope around the whole page for a global preference, or around one
  panel for a local one — nested scopes each win inside their own subtree.
- Persistence: storageKey for a per-browser preference; drop it and lift the value with
  value/onValueChange when the density belongs to a saved view or a server-side profile.

Concepts

The seven properties DensityScope publishes, and what each step sets them to. A descendant opts in by naming a variable — never by receiving a prop:

VariableMeaningcompact · comfortable · spacious
--density-row-hminimum height of one row / list item32px · 40px · 52px
--density-pad-xhorizontal padding inside a cell or row8px · 12px · 16px
--density-pad-yvertical padding inside a cell or row4px · 8px · 12px
--density-gapgap between stacked items4px · 8px · 12px
--density-control-hminimum size of an interactive target24px · 32px · 40px (never below 24px; 44px on coarse pointers)
--density-font-sizebody font size, emitted in rem0.8125rem · 0.875rem · 0.9375rem
--density-line-heightunitless line height1.35 · 1.5 · 1.7

--density-font-size and --density-line-height are also applied to the scope element itself, because those two inherit — text follows with no opt-in at all. The other five stay inert until a descendant reads them:

<DensityScope storageKey="inbox-density">
  <DensityToggle />

  <table>
    <tr className="h-[var(--density-row-h)]">
      <td className="px-[var(--density-pad-x)] py-[var(--density-pad-y)]">…</td>
    </tr>
  </table>

  <button className="min-h-[var(--density-control-h)] min-w-[var(--density-control-h)]">…</button>
</DensityScope>

Use h- on a <tr> (a table row treats height as a minimum) and min-h- everywhere else, so a row that needs two lines can still grow. Scopes nest: an inner DensityScope wins inside its own subtree.

  • Density as a variable scope — the component's whole output is seven inherited custom properties on one element. Descendants opt in by naming a variable, so a table nested ten levels deep follows along without a single prop being threaded through it, and two scopes on one page can hold different densities at once.
  • A floor that is a contract, not a default--density-control-h resolves as max(<step>px, var(--density-touch-floor, 24px)), and a hoisted @media (pointer: coarse) rule raises that floor to 44px. The compact step therefore cannot produce a target below the WCAG 2.2 minimum, even when a consumer's own scale asks for 8px; a second, independent clamp in resolveDensityStep refuses the same value in JS before it ever reaches CSS.
  • Cyclic custom properties compute to nothing — the tempting --density-control-h: max(var(--density-control-h), 44px) is a self-reference, which CSS treats as invalid at computed-value time; the floor has to arrive through a separate variable, which is why --density-touch-floor exists.
  • Radiogroup, not a pressed toolbar — three mutually exclusive steps with exactly one always active is aria-checked semantics; aria-pressed would announce three independent toggles. That difference also decides the keyboard model: focus follows selection, so an arrow key both moves and picks.
  • Storage that survives the server — the persisted value is read through useSyncExternalStore with a server snapshot equal to the default, so the server render and the first client frame agree and the stored step is adopted only after hydration. A stale or hand-edited value that is not one of the three steps normalizes back to the default rather than emitting junk CSS.
  • Decided ownership, never mixed — controlled (value), persisted (storageKey) and uncontrolled are three modes chosen once at mount; whichever holds the value, onValueChange still reports every change, so a parent can observe without taking over.

On This Page