Skip to contentVibraUI
Foundation

useDensity

Reads and writes the row rhythm the whole document is laid out at.

The document is the source of truth, not React state: the hook reads data-density off <html> through useSyncExternalStore, so a page that set the attribute itself — a block passing density to AppShell, a server render, a preview frame honouring ?density= — is what every toggle on the page starts from. Writing goes to both the attribute and localStorage["vibra-density"], and every instance on the page moves together with no provider between them; a storage event from another tab moves them too. On mount it restores a stored choice only when the document does not already carry one, so a page that has decided its own density keeps it. Density is four variables (--density-row, --density-cell-y, --density-card, --density-gap) that live in the stylesheet the density item ships — registry/vibra/themes/density.css, installed to app/vibra-density.css — which sets them at :root and tightens them under [data-density="compact"], and which comes with this hook as a dependency. The primitives read them with the comfortable value as a fallback, so an item installed without that stylesheet still lays out correctly and only the switch is missing.

Install

npx shadcn@latest add @vibra/use-density

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

Examples

Props

PropTypeDefaultDescription
returns.density"comfortable" | "compact"—What <html> currently says; "comfortable" on the server and when nothing has been chosen.
returns.setDensity(next: "comfortable" | "compact") => void—Sets the attribute, stores the choice, and moves every other instance on the page.
applyDensity(density: "comfortable" | "compact") => void—The same write, outside React — for a settings form or a keyboard shortcut.

Dependencies

Source

hooks/use-density.ts
"use client"

import * as React from "react"

export type Density = "comfortable" | "compact"

/** Where the choice is kept, so it survives a reload and travels between tabs. */
export const DENSITY_STORAGE_KEY = "vibra-density"

/** The attribute the CSS reads; `[data-density="compact"]` tightens the rhythm. */
export const DENSITY_ATTRIBUTE = "data-density"

const DEFAULT_DENSITY: Density = "comfortable"

function isDensity(value: string | null | undefined): value is Density {
  return value === "comfortable" || value === "compact"
}

// One set for every hook instance on the page: a toggle in the header and a
// switch in a settings panel move together, without a provider between them.
const listeners = new Set<() => void>()

function emit() {
  for (const listener of listeners) listener()
}

function subscribe(onStoreChange: () => void): () => void {
  listeners.add(onStoreChange)
  // Another tab writing the key is a change to the same choice.
  window.addEventListener("storage", onStoreChange)
  return () => {
    listeners.delete(onStoreChange)
    window.removeEventListener("storage", onStoreChange)
  }
}

/**
 * The document is the source of truth, not a React state: the attribute is what
 * the CSS actually reads, and a page may set it server-side (AppShell does).
 * Returning a string keeps the snapshot stable by value, which is what
 * useSyncExternalStore needs.
 */
function getSnapshot(): Density {
  const attribute = document.documentElement.getAttribute(DENSITY_ATTRIBUTE)
  return isDensity(attribute) ? attribute : DEFAULT_DENSITY
}

function getServerSnapshot(): Density {
  return DEFAULT_DENSITY
}

/** Every storage access is wrapped: a full, disabled or partitioned store never throws. */
function readStored(): Density | undefined {
  try {
    const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY)
    return isDensity(stored) ? stored : undefined
  } catch {
    return undefined
  }
}

function writeStored(density: Density) {
  try {
    window.localStorage.setItem(DENSITY_STORAGE_KEY, density)
  } catch {
    // A reader with storage turned off still gets the switch, just not the memory.
  }
}

/** Sets the attribute the whole page is laid out from, and remembers the choice. */
export function applyDensity(density: Density) {
  document.documentElement.setAttribute(DENSITY_ATTRIBUTE, density)
  writeStored(density)
  emit()
}

/**
 * The reader's row rhythm: `comfortable` (the default) or `compact`.
 *
 * Reads `document.documentElement`'s `data-density` and writes both it and
 * `localStorage["vibra-density"]`. On mount it restores a stored choice the
 * document does not already carry, so the switch survives a reload; a page
 * that sets the attribute itself — a block passing `density` to AppShell —
 * wins over what the reader last picked, because that is a decision the page
 * has made about its own layout.
 *
 * Restoring it here costs a paint: every row, card and gap resizes after the
 * content is on screen. An app that cares — this one does — writes the
 * attribute from a blocking inline script in the document head before anything
 * is drawn, and leaves this effect as the fallback.
 */
export function useDensity(): { density: Density; setDensity: (next: Density) => void } {
  const density = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

  React.useEffect(() => {
    if (document.documentElement.hasAttribute(DENSITY_ATTRIBUTE)) return
    const stored = readStored()
    if (stored) applyDensity(stored)
  }, [])

  const setDensity = React.useCallback((next: Density) => applyDensity(next), [])

  return { density, setDensity }
}