ui
Density toggle
Comfortable / compact, persisted, applied by attribute so nothing has to be prop-drilled.
npx shadcn@latest add @hextor/density-toggleSource
"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