a group named on its edge, the name in a gap in the border, for dense panels
npx shadcn add @ag/group-boxAlready installed? Preview an update
npx shadcn add @ag/group-box --dry-runYour 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.
the demo above, verbatim
"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
"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}
</>
),
},
})
}