HextorUI
ui

Density toggle

Comfortable / compact, persisted, applied by attribute so nothing has to be prop-drilled.

npx shadcn@latest add @hextor/density-toggle

Source

"use client";

import * as React from "react";
import { createContext, useContext, useEffect, useMemo } from "react";
import { AlignJustify, Rows3 } from "lucide-react";
import { cn } from "@/registry/hextor/lib/utils";
import { useLocalStorageState } from "@/registry/hextor/hooks/use-local-storage-state";
import {
  resolveLabels,
  type DensityToggleLabels,
} from "@/registry/hextor/ui/density-toggle-labels";

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

interface DensityContextValue {
  density: Density;
  setDensity: (density: Density) => void;
}

const DensityContext = createContext<DensityContextValue | null>(null);

export interface DensityProviderProps {
  children: React.ReactNode;
  /** localStorage key. Namespace it if a page hosts more than one density scope. */
  storageKey?: string;
  defaultDensity?: Density;
}

/**
 * Owns the density preference and writes it as `data-density` on the
 * document root — not on a wrapper `<div>` this component renders. A
 * wrapper would need `display: contents` to avoid adding a layout box,
 * which drops out of the accessibility tree inconsistently across browsers;
 * the document root has neither problem and is exactly what "a data
 * attribute on a root element" means for something table rows and list
 * rows anywhere on the page need to read via a plain CSS descendant
 * selector — `[data-density="compact"] td { ... }` — with no prop drilling.
 *
 * The attribute lands in an effect, after mount, matching every other
 * localStorage-backed preference in this registry: correct after paint
 * beats a blocking inline script this component has no way to inject.
 */
export function DensityProvider({
  children,
  storageKey = "hextor:density",
  defaultDensity = "comfortable",
}: DensityProviderProps) {
  const [density, setDensity] = useLocalStorageState<Density>(storageKey, defaultDensity);

  useEffect(() => {
    document.documentElement.setAttribute("data-density", density);
    return () => {
      document.documentElement.removeAttribute("data-density");
    };
  }, [density]);

  const value = useMemo<DensityContextValue>(() => ({ density, setDensity }), [density, setDensity]);

  return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>;
}

export function useDensity(): DensityContextValue {
  const ctx = useContext(DensityContext);
  if (!ctx) throw new Error("useDensity must be used within a <DensityProvider>.");
  return ctx;
}

export interface DensityToggleProps {
  labels?: Partial<DensityToggleLabels>;
  className?: string;
}

/** The comfortable/compact segmented control. Reads and writes via `useDensity`. */
export function DensityToggle({ labels: labelsProp, className }: DensityToggleProps) {
  const { density, setDensity } = useDensity();
  const labels = resolveLabels(labelsProp);
  const options: { value: Density; label: string; icon: typeof Rows3 }[] = [
    { value: "comfortable", label: labels.comfortable, icon: Rows3 },
    { value: "compact", label: labels.compact, icon: AlignJustify },
  ];

  return (
    <div
      role="group"
      aria-label={labels.groupLabel}
      data-slot="density-toggle"
      className={cn(
        "inline-flex items-center gap-0.5 rounded-lg border border-border bg-background p-0.5",
        className,
      )}
    >
      {options.map(({ value, label, icon: Icon }) => {
        const active = density === value;
        return (
          <button
            key={value}
            type="button"
            aria-pressed={active}
            onClick={() => setDensity(value)}
            className={cn(
              "inline-flex h-6.5 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors outline-none focus-visible:ring-3 focus-visible:ring-brand-ring",
              active ? "bg-brand text-brand-foreground" : "text-muted-foreground hover:text-foreground",
            )}
          >
            <Icon aria-hidden className="size-3.5" />
            {label}
          </button>
        );
      })}
    </div>
  );
}

Docs

Writes data-density on the document element, so any table or list reads it with a plain attribute selector. Threading a density prop through every component that cares is how the setting ends up applied in four places out of seven.

Dependencies

lucide-reactbutton@hextor/app-hooks