← ui

group-box

ui

a group named on its edge, the name in a gap in the border, for dense panels

npx shadcn add @ag/group-box
Already installed? Preview an update
npx shadcn add @ag/group-box --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • In glass the group is a glass Panel, its name and aside on the panel's head. It used to rebuild the glass by hand, its body blurring on its own root.
Updated 2026-10-14 · release notes

also called fieldset

shelf controls

uses material, panel

used by components

the demo above, verbatim

group-box.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { Badge } from "@/registry/base-nova/ui/badge"
import { GroupBox } from "@/registry/base-nova/ui/group-box"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Readout, ReadoutRow } from "@/registry/base-nova/ui/readout"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/registry/base-nova/ui/resizable"

// #region operations
/** A service's operations page: a group per thing it runs, each named on its edge,
 *  as many to a row as fit. */
function Operations() {
  return (
    <div className="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4">
      <GroupBox label="queue" aside="written 12 s ago">
        <Readout>
          <ReadoutRow label="waiting">212</ReadoutRow>
          <ReadoutRow label="in flight">8</ReadoutRow>
          <ReadoutRow label="process, p95">140 ms</ReadoutRow>
          <ReadoutRow label="errors">0</ReadoutRow>
          <ReadoutRow label="last job">4 s ago</ReadoutRow>
        </Readout>
      </GroupBox>
      <GroupBox label="workers" aside="2 restarting" tone="warn">
        <Readout>
          <ReadoutRow label="healthy" tone="warn">
            14 of 16
          </ReadoutRow>
          <ReadoutRow label="cpu avg">62 %</ReadoutRow>
          <ReadoutRow label="memory avg">1.8 GB</ReadoutRow>
          <ReadoutRow label="oldest">3 d 4 h</ReadoutRow>
        </Readout>
      </GroupBox>
      <GroupBox label="database" aside="probed 3 s ago">
        <Readout>
          <ReadoutRow label="state">
            <Badge variant="success">primary</Badge>
          </ReadoutRow>
          <ReadoutRow label="size">4.2 GB</ReadoutRow>
          <ReadoutRow label="replica lag" tone="bad">
            41 s
          </ReadoutRow>
          <ReadoutRow label="schema">
            <a
              href="/updates"
              className="underline decoration-current/35 underline-offset-2 hover:decoration-current"
            >
              2026.10.11
            </a>
          </ReadoutRow>
        </Readout>
      </GroupBox>
      <GroupBox label="sync" aside="every 30 s">
        <Readout>
          <ReadoutRow label="last sync">18 s ago</ReadoutRow>
          <ReadoutRow label="records · skipped">1,204 · 3</ReadoutRow>
          <ReadoutRow label="source">billing api</ReadoutRow>
        </Readout>
      </GroupBox>
    </div>
  )
}
// #endregion

export default function GroupBoxDemo() {
  return (
    <div className="space-y-8">
      <Sample name="queue" glance title="a group named on its edge">
        <GroupBox label="queue" aside="12 s ago" className="w-72">
          <Readout>
            <ReadoutRow label="waiting">212</ReadoutRow>
            <ReadoutRow label="healthy workers" tone="warn">
              14 of 16
            </ReadoutRow>
            <ReadoutRow label="last job">4 s ago</ReadoutRow>
          </Readout>
        </GroupBox>
      </Sample>
      <Sample
        name="operations"
        title="an operations page"
        description="a group in a tone has its edge and name in it; its rows keep their own."
        with="operations"
      >
        <Operations />
      </Sample>
      <Sample
        name="glass"
        title="glass: the name on the front"
        description="a glass edge is a lit rim, and a gap would break the light: the name and the aside stand on the pane's front surface, the rows on the body under it."
        with="operations"
      >
        <MaterialProvider material="glass">
          <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
            <Operations />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="narrow"
        title="narrow, the rows stack"
        description="drag the handle: a readout under 24 characters wide sets each name over its value."
      >
        <div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
          <ResizablePanelGroup>
            <ResizablePanel defaultSize={0.25} minSize={0.15}>
              <div className="p-4">
                <GroupBox label="queue" aside="12 s ago">
                  <Readout>
                    <ReadoutRow label="waiting">212</ReadoutRow>
                    <ReadoutRow label="healthy workers" tone="warn">
                      14 of 16
                    </ReadoutRow>
                    <ReadoutRow label="last job">4 s ago</ReadoutRow>
                  </Readout>
                </GroupBox>
              </div>
            </ResizablePanel>
            <ResizableHandle withHandle aria-label="the group's width" />
            <ResizablePanel>
              <div className="p-4">
                <GroupBox label="database" aside="probed 3 s ago">
                  <Readout>
                    <ReadoutRow label="size">4.2 GB</ReadoutRow>
                    <ReadoutRow label="replica lag" tone="bad">
                      41 s
                    </ReadoutRow>
                  </Readout>
                </GroupBox>
              </div>
            </ResizablePanel>
          </ResizablePanelGroup>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

group-box.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  TONE_WORDS,
  type Tone,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassWords, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Panel } from "@/registry/base-nova/ui/panel"

// A GROUP UNDER A NAME, THE NAME ON ITS EDGE: a hairline box whose name stands in the
// top border at the left, so a group costs no heading row and a page holds many dense
// groups that read as an instrument panel (the toolkits' group box: Qt's QGroupBox,
// Windows' GroupBox, Tk's labelframe). `aside` stands in the same border at the right,
// apart from the name and quieter: when it was written, how many there are.
//
//   <GroupBox label="queue" aside="12 s ago">
//     <Readout>…</Readout>
//   </GroupBox>
//
// THE NAME STANDS IN A REAL GAP: the border is not drawn behind it, so it reads over
// any ground (a picture, a gradient, the page) with nothing painted under it. The
// border is four pieces laid on the box's own grid, in the cells round the name and
// the aside: the left one (its top corner, its side, its bottom corner), the top
// between the name and the aside, the bottom, the right one. Where the name and the
// aside stand there is no piece, so the gap is as wide as they are, however wide that
// is, with no measuring. A `fieldset` cuts one gap natively, for its legend only (the
// aside would need a second), and says a form; this is a group of what is shown, so it
// is `role="group"` named by its name (`aria-labelledby`), which a screen reader says
// on the way in.
//
// `tone` puts the whole group in a tone, the tokens' (`warn`, `bad`, `ok`): its edge
// and its name in that colour. Its rows can carry their own (readout's `tone`).
//
// GLASS HAS NO CUT. A glass edge is a lit rim and a gap would break the light, so in
// glass the group is a glass `Panel` (panel's), its name and aside on the panel's
// head, the material's front surface: a strip of glass across the top, one straight
// lit hairline as its inner edge, the name at its left and the aside at its right.
// What the group holds lies on the body under it, the panel's edge unbroken over
// both. A tone is the glass's stain, on the body and the head alike, said again on
// the name.

const TONE_INK: Record<Tone, string> = {
  ok: "var(--tone-ok)",
  warn: "var(--tone-warn)",
  bad: "var(--tone-bad)",
}

/** The border's pieces in the text's colour, or the tone's. */
const EDGE: Record<Tone | "none", string> = {
  none: "border-current/15",
  ok: "border-tone-ok/55",
  warn: "border-tone-warn/55",
  bad: "border-tone-bad/55",
}

// The geometry, one 16 px rhythm: the content sits 16 px in from the sides and the
// foot, and 16 px under the top border. The edge row is the name's one line (16 px)
// and the border runs through its middle (8 px down). The name's gap is 6 px of
// border either side of its words (`px-1.5`), its words 16 px in, over the content's
// first column: the corner piece is the rest, 10 px.
/** The grid: corner, name, the top between, aside, corner. */
const GRID =
  "grid grid-cols-[10px_auto_minmax(0,1fr)_auto_10px] grid-rows-[auto_1fr]"
/** A piece of the border: drawn, never hit. */
const PIECE = "pointer-events-none"
/** From the edge row's middle, where the border runs. */
const FROM_EDGE = "mt-2"
const LINE = "leading-4"

// Glass: the front holds the name's one line (16 px) with 12 px of glass above and
// below it; the content starts 16 px under the front's hairline, its words 16 px in,
// under the name's.
/** The pane's corners, one length: its body's, its front's and its edge's. */
const GLASS_RADIUS = "1rem"
/** The front surface's height, which the content pads by. */
const FRONT_HEIGHT = "2.5rem"

export type GroupBoxProps = useRender.ComponentProps<"div"> & {
  /** What the group is, on its edge (on the front surface in glass). */
  label: React.ReactNode
  /** At the edge's other end, quieter: when it was written, a count. */
  aside?: React.ReactNode
  /** The whole group in a tone: its edge and its name. */
  tone?: Tone
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's.
   *  Named, its words are that scheme's; unnamed, the text's around it. */
  scheme?: Scheme
}

export function GroupBox({
  label,
  aside,
  tone,
  material,
  scheme,
  render,
  className,
  style,
  children,
  ref,
  ...props
}: GroupBoxProps) {
  const m = useMaterial(material)
  const labelId = `${React.useId()}-label`
  const glass = m === "glass"
  const name = (
    <span
      id={labelId}
      data-slot="group-box-label"
      className={cn(
        "min-w-0 font-medium text-xs",
        LINE,
        !glass && "col-start-2 row-start-1 px-1.5",
        tone ? TONE_WORDS[tone] : "text-current/80",
        tone && glass && glassWords,
      )}
    >
      {label}
    </span>
  )
  // An aside is what React would draw: `aside={stale && "12 s ago"}` while not stale
  // is none, and the top border runs to the corner.
  const hasAside =
    aside !== undefined && aside !== null && aside !== false && aside !== ""
  const asideNode = hasAside && (
    <span
      data-slot="group-box-aside"
      className={cn(
        "min-w-0 text-right font-mono text-[11px] text-current/45 tabular-nums",
        LINE,
        !glass && "col-start-4 row-start-1 px-1.5",
      )}
    >
      {aside}
    </span>
  )
  if (glass)
    // Glass is the one glass container, a Panel, its head the front: the name and
    // the aside on it, what the group holds on the body under it.
    return (
      <Panel
        ref={ref}
        render={render}
        role="group"
        aria-labelledby={labelId}
        data-slot="group-box"
        data-tone={tone}
        material="glass"
        scheme={scheme}
        radius={GLASS_RADIUS}
        headHeight={FRONT_HEIGHT}
        headClassName="justify-between gap-3 px-4"
        bodyClassName="block min-w-0 p-4"
        tint={tone ? TONE_INK[tone] : undefined}
        className={cn("min-w-0", className)}
        style={style}
        head={
          <>
            {name}
            {asideNode}
          </>
        }
        {...props}
      >
        {children}
      </Panel>
    )
  return (
    <PlainGroupBox
      labelId={labelId}
      name={name}
      asideNode={asideNode}
      hasAside={hasAside}
      tone={tone}
      scheme={scheme}
      render={render}
      className={className}
      style={style}
      ref={ref}
      {...props}
    >
      {children}
    </PlainGroupBox>
  )
}

/** The plain face: the hairline box, its name in a gap in its top border. */
function PlainGroupBox({
  labelId,
  name,
  asideNode,
  hasAside,
  tone,
  scheme,
  render,
  className,
  style,
  children,
  ref,
  ...props
}: Omit<GroupBoxProps, "label" | "aside" | "material"> & {
  labelId: string
  name: React.ReactNode
  asideNode: React.ReactNode
  hasAside: boolean
}) {
  const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const edge = EDGE[tone ?? "none"]
  // What is inside stands on the group: it wears the group's scheme, whatever
  // decided it.
  const content = (
    <div
      data-slot="group-box-content"
      className="col-span-full row-start-2 min-w-0 px-4 pt-2 pb-4"
    >
      <MaterialProvider scheme={s}>{children}</MaterialProvider>
    </div>
  )
  return useRender({
    defaultTagName: "div",
    render,
    ref: here,
    props: {
      role: "group",
      "aria-labelledby": labelId,
      "data-slot": "group-box",
      "data-material": "plain",
      "data-tone": tone,
      style,
      className: cn(
        "min-w-0",
        schemeOf(s),
        GRID,
        // Plain draws in the text's colour; a scheme named is that scheme's words.
        s !== "auto" && "text-foreground scheme-light:text-zinc-950",
        className,
      ),
      ...props,
      children: (
        <>
          <span
            aria-hidden
            data-slot="group-box-edge"
            className={cn(
              PIECE,
              FROM_EDGE,
              edge,
              "col-start-1 row-span-2 row-start-1 rounded-l-lg border-y border-l",
            )}
          />
          <span
            aria-hidden
            data-slot="group-box-edge"
            className={cn(
              PIECE,
              FROM_EDGE,
              edge,
              "row-start-1 self-start border-t",
              hasAside ? "col-start-3" : "col-[3/5]",
            )}
          />
          <span
            aria-hidden
            data-slot="group-box-edge"
            className={cn(
              PIECE,
              edge,
              "col-[2/5] row-start-2 self-end border-b",
            )}
          />
          <span
            aria-hidden
            data-slot="group-box-edge"
            className={cn(
              PIECE,
              FROM_EDGE,
              edge,
              "col-start-5 row-span-2 row-start-1 rounded-r-lg border-y border-r",
            )}
          />
          {name}
          {asideNode}
          {content}
        </>
      ),
    },
  })
}