Skip to documentation content

Density

Set compact, default, or comfortable density for a whole section with one scope, and let nested sections override it.

density-demo

Overview

A density scope sets the size of controls, menu items, table rows, and gaps for everything inside one element. You mark the section once instead of passing a size to every component in it.

There are three levels: compact, default, and comfortable. default matches the size every component has outside a scope.

Anatomy

Density has two parts. The honestui/density.css stylesheet defines the variables for each level on [data-density] and adds three Tailwind variants. DensityScope renders the element that carries data-density and shares the level through React context, which is how portaled popups and DataGrid receive it. DensityProvider shares a level without rendering an element, and useDensity reads it.

Accessibility

Density changes size only. Roles, names, focus order, focus rings, and font sizes are the same at every level.

Touch behavior

On devices whose primary pointer is coarse (@media (pointer: coarse)), every scope gives controls, menu items, and table rows a minimum height of 44px. Padding and gaps keep their level.

Compact rows are stacked edge to edge, so an invisible 44px hit area on a 28px control would overlap the row above and below. A real 44px box avoids that. The floor applies to all three levels so that compact is never larger than default on the same screen.

Two things to know:

  • Outside a scope, components keep their existing touch behavior. Set a scope on your app root if you want the 44px floor everywhere.
  • A control with an explicit size, such as <Button size="sm">, keeps that size at every density and on touch.

Change the floor by setting --hui-density-touch-target on :root or on a scope.

Installation

Density is optional. Components read each density variable with a fallback to their own size, so nothing changes until you load the stylesheet and set a scope.

Import the stylesheet after Tailwind CSS and the Honest UI tokens. It uses the spacing tokens.

@import "tailwindcss";
@import "honestui/density.css";

Add DensityScope to your project. Menu, Select, DataTable, and DataGrid import it, so the CLI installs it with them.

npx honestui@latest add density

To add it by hand, copy this file:

components/ui/density.tsx
"use client"

import * as React from "react"

type Density = "compact" | "default" | "comfortable"

const DensityContext = React.createContext<Density | undefined>(undefined)

/**
 * The density of the nearest `DensityScope` or `DensityProvider`, or
 * `undefined` outside one. A bare `data-density` attribute is CSS-only and is
 * not visible here.
 */
function useDensity(): Density | undefined {
  return React.useContext(DensityContext)
}

/**
 * Shares a density with descendants without rendering an element. Use it when
 * the element that carries `data-density` is rendered elsewhere.
 */
function DensityProvider({
  density,
  children,
}: {
  density: Density
  children: React.ReactNode
}) {
  return (
    <DensityContext.Provider value={density}>{children}</DensityContext.Provider>
  )
}

/**
 * Sets the density for a section. Popups such as menus and select lists are
 * portaled out of the section's DOM subtree, so they read the density from
 * this component rather than from CSS inheritance.
 */
function DensityScope({
  density,
  children,
  ...props
}: React.ComponentPropsWithRef<"div"> & { density: Density }) {
  return (
    <div data-slot="density-scope" data-density={density} {...props}>
      <DensityProvider density={density}>{children}</DensityProvider>
    </div>
  )
}

export { DensityProvider, DensityScope, useDensity, type Density }

Usage

Use DensityScope for a section:

import { DensityScope } from "@/components/ui/density"

export function InvoicesPanel() {
  return (
    <DensityScope density="compact">
      <InvoiceToolbar />
      <InvoiceTable />
    </DensityScope>
  )
}

DensityScope renders a div with data-density and accepts any div prop.

A bare attribute also works when the section has no popups, DataTable, or DataGrid:

<section data-density="compact">…</section>

Menus and select lists are portaled to the end of the document, outside the section's DOM subtree, so CSS inheritance cannot reach them. They read the density from DensityScope instead. DataTable and DataGrid read it there too: they resolve their density in React, and DataGrid needs the row height in JavaScript for virtualization.

Read the density in a component

useDensity() returns the density of the nearest DensityScope, or undefined outside one. It does not see a bare data-density attribute.

import { useDensity } from "@/components/ui/density"

function RowAvatar() {
  const density = useDensity()
  return density === "compact" ? null : <Avatar />
}

Use DensityProvider to share a density without rendering an element, for example when your own component already renders the element that carries data-density.

Examples

Nested scopes

The nearest scope wins. A comfortable form inside a compact panel stays comfortable, and a sibling section without a scope keeps the component defaults.

density-nested

Components that follow density

ComponentWhat changes
ButtonHeight and padding of the default size; width and height of size="icon"
InputHeight of the default size
SelectTrigger height and padding of the default size; item padding
MenuItem padding
TableCell and header padding
ToolbarGap and padding
DataTableRow padding; density prop now also accepts comfortable
DataGridRow height (44px, 56px, or 64px)

DataTable and DataGrid use their density prop when it is set and inherit from DensityScope when it is not. Either way they become a scope for the buttons, inputs, and menus inside them.

Variables

Each level sets these variables on the scope element. Use them in your own components with a fallback, the same way the built-in components do.

Honest UI density variables
TokenValueUse
--hui-density-control-height28px / 32px / 40pxHeight of buttons, inputs, and select triggers. 44px in a scope on touch.
--hui-density-control-padding-block4px / 8px / 12pxVertical padding of buttons and select triggers
--hui-density-control-padding-inline8px / 12px / 16pxHorizontal padding of buttons
--hui-density-item-padding-block4px / 8px / 12pxVertical padding of menu and select items
--hui-density-cell-padding-block4px / 12px / 16pxVertical padding of table cells
--hui-density-cell-padding-inline8px / 8px / 12pxHorizontal padding of table cells and headers
--hui-density-header-padding-block4px / 8px / 12pxVertical padding of table headers
--hui-density-gap4px / 8px / 12pxGap between related controls in the section
--hui-density-group-gap1px / 2px / 4pxGap and padding inside a toolbar
--hui-density-item-min-height44px on touchMinimum height of menu and select items. Set only in a scope on touch.
--hui-density-row-min-height44px on touchMinimum height of table rows. Set only in a scope on touch.
--hui-density-touch-target44pxThe touch floor used by the three variables above

Values are listed as compact / default / comfortable.

<div className="grid gap-[var(--hui-density-gap,var(--hui-space-3))]">
  <button className="min-h-[var(--hui-density-control-height,var(--hui-space-9))]">
    Refresh
  </button>
</div>

Tailwind variants

The stylesheet adds three Tailwind CSS v4 variants for changes a variable cannot express, such as hiding secondary text in compact sections:

<p className="density-compact:hidden">Last synced 2 minutes ago</p>
<Avatar className="size-8 density-compact:size-6 density-comfortable:size-10" />

density-compact:, density-default:, and density-comfortable: match the scope element and the elements whose nearest scope has that level.

One limit: when a level is re-entered through a different level, such as compact inside comfortable inside compact, the variant does not match in the innermost scope. The variables have no such limit, so prefer them for sizing.

API reference

DensityScope

PropTypeDefaultDescription
density"compact" | "default" | "comfortable"RequiredThe density for this section.
...propsReact.ComponentProps<"div">—Passed to the rendered div.

DensityProvider

PropTypeDefaultDescription
density"compact" | "default" | "comfortable"RequiredThe density shared with descendants.