docs / patterns / panel

Panel

the dashboard workhorse: it fills its grid cell, the header stays fixed, and the body scrolls its own overflow.

intensity · structuresrsc · server

provenance

shaped from sisu-plus

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

a cell of DashboardGrid or a bento layout. it needs a sized boxbadge takes a Badge, actions take icon-only Buttonsnever nested in a Card or another Panel. it is the regionnot a page section on a marketing page. that is Card territory

a11y

the loading spinner is role="status" with an sr label · badge and actions keep their own semanticsjest-axe

dependencies

Spinner · Card from the same package
live demo · try it out

overview

Deployments
live
  • feat/docs

    1ec3bbc · just now

    building
  • fix/hero-split

    0df772e · 14m ago

    live
  • chore/tokens

    c74cf7c · 1h ago

    live
  • feat/color-field

    fa17ccd · 3h ago

    live
  • fix/bento-grid

    9d2d01e · 5h ago

    failed
  • feat/segmented

    8b21f04 · yesterday

    live
  • chore/registry

    a90c7d1 · yesterday

    queued
customize
eyebrowmono label above the title
titlethe panel heading
surfacehow it sits on the page
badgea status Badge in the header
loadingswaps the body for a Spinner
usagecopy
import { Panel } from "usva/patterns/panel";
import { Badge } from "usva/primitives/badge";

<Panel
  eyebrow="overview"
  title="Deployments"
  badge={<Badge tone="accent-alt" live>live</Badge>}
>
  <DeploymentList />
</Panel>

props

proptypedefaultnotes
titleReactNodethe panel heading.
eyebrowReactNodemono uppercase label above the title.
iconReactNodeleading icon, shown in a tile.
badgeReactNoderight-aligned status slot. pass a Badge.
actionsReactNoderight-aligned controls slot. icon-only Buttons fit.
loadingbooleanfalseswaps the body for a centered Spinner. the header stays.
loadingSlotReactNodereplaces the default spinner while loading. a Skeleton stack goes here.
surfaceCardSurface"elevated"how the panel sits on the page. shares Card's surface scale.

get it

npx shadcn add https://usva.build/r/panel.jsoncopy
source · components/ui/panel.tsxexactly what this command copiescopy
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  type CardSurface,
  SURFACE_ELEVATED,
  SURFACE_SKIN,
} from "./card";
import { Spinner } from "./spinner";

export interface PanelProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  icon?: React.ReactNode;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  badge?: React.ReactNode;
  actions?: React.ReactNode;
  loading?: boolean;
  /** Replaces the default centered spinner while `loading`. */
  loadingSlot?: React.ReactNode;
  /** How the panel sits on the page. Defaults to elevated. */
  surface?: CardSurface;
}

/**
 * The dashboard panel. A full-height card with a mono eyebrow header, an icon
 * tile, badge/actions slots, and a loading state. Built to live in a grid cell:
 * it fills its box and scrolls its body. usva's "usability pole" workhorse.
 */
export const Panel = React.forwardRef<HTMLDivElement, PanelProps>(
  (
    {
      className,
      icon,
      eyebrow,
      title,
      badge,
      actions,
      loading = false,
      loadingSlot,
      surface = "elevated",
      children,
      ...props
    },
    ref,
  ) => (
    <div
      ref={ref}
      data-surface={surface}
      className={cn(
        "relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-2xl border border-border text-ink",
        SURFACE_SKIN[surface],
        SURFACE_ELEVATED[surface] && "shadow-floating",
        className,
      )}
      {...props}
    >
      <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
        <div className="flex min-w-0 flex-1 items-center gap-2.5">
          {icon != null && (
            <span className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-surface-2 text-muted [&_svg]:h-4 [&_svg]:w-4">
              {icon}
            </span>
          )}
          <div className="min-w-0">
            {eyebrow != null && (
              <p className="font-mono text-[0.625rem] font-semibold uppercase leading-none tracking-[0.12em] text-muted">
                {eyebrow}
              </p>
            )}
            <div className="mt-1 truncate text-sm font-semibold tracking-[-0.01em] text-ink">
              {title}
            </div>
          </div>
        </div>
        {(badge != null || actions != null) && (
          <div className="flex shrink-0 items-center gap-2">
            {badge}
            {actions}
          </div>
        )}
      </div>

      <div className="min-h-0 flex-1 overflow-y-auto p-4">
        {loading
          ? (loadingSlot ?? (
              <div className="grid h-full min-h-24 place-items-center">
                <Spinner />
              </div>
            ))
          : children}
      </div>
    </div>
  ),
);
Panel.displayName = "Panel";