Skip to contentVibraUI
Inputs & filters

Density toggle

A segmented control that switches the whole page between comfortable and compact rows.

A client component: it writes data-density onto <html> through useDensity, which the stylesheet the density item ships (registry/vibra/themes/density.css, installed to app/vibra-density.css) turns into the four variables the data-table rows, the stat-card padding, the grid gaps, the list items and the sidebar all read — so one control retightens a dashboard rather than one table. The group is labelled "Density" and each option is a radio; iconOnly keeps both names in the accessible tree as sr-only text rather than dropping them, so the two icons stay distinguishable to anyone not looking at them.

Install

npx shadcn@latest add @vibra/density-toggle

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
iconOnlybooleanfalseDrops the words and keeps the two icons; each option keeps its accessible name.
size"sm" | "default""sm"Passed through to SegmentedControl; sm suits a header bar.
fullWidthbooleanfalseStretches the track and shares its width between the two options.

Dependencies

Source

components/ui/density-toggle.tsx
"use client"

import * as React from "react"
import { FoldVerticalIcon, UnfoldVerticalIcon } from "lucide-react"

import {
  SegmentedControl,
  type SegmentedControlOption,
  type SegmentedControlProps,
} from "@/components/ui/segmented-control"
import { useDensity, type Density } from "@/hooks/use-density"

const WORDS: { value: Density; label: string; icon: React.ReactNode }[] = [
  { value: "comfortable", label: "Comfortable", icon: <UnfoldVerticalIcon /> },
  { value: "compact", label: "Compact", icon: <FoldVerticalIcon /> },
]

// Icon-only still names each option — the word moves into the accessible tree
// rather than out of it, so the control keeps two distinguishable radios.
const OPTIONS: Record<"words" | "icons", SegmentedControlOption[]> = {
  words: WORDS.map((entry) => ({ value: entry.value, label: entry.label, icon: entry.icon })),
  icons: WORDS.map((entry) => ({
    value: entry.value,
    label: <span className="sr-only">{entry.label}</span>,
    icon: entry.icon,
  })),
}

export type DensityToggleProps = Omit<
  SegmentedControlProps,
  "options" | "value" | "onValueChange"
> & {
  /** Drops the words and keeps the two icons, for a header bar that is already full. */
  iconOnly?: boolean
}

/**
 * The row rhythm, as a two-option segmented control.
 *
 * It writes `data-density` onto `<html>`, which is what every primitive that
 * sets a dashboard's rhythm reads — so one control retightens the tables, the
 * cards, the gaps and the sidebar at once. The choice is remembered in
 * `localStorage["vibra-density"]`.
 */
function DensityToggle({ size = "sm", iconOnly = false, ...props }: DensityToggleProps) {
  const { density, setDensity } = useDensity()

  return (
    <SegmentedControl
      data-slot="density-toggle"
      aria-label="Density"
      size={size}
      options={OPTIONS[iconOnly ? "icons" : "words"]}
      value={density}
      onValueChange={(value) => setDensity(value as Density)}
      {...props}
    />
  )
}

export { DensityToggle }