Lockup
A centered stack of icon, heading, description, and actions that stands in for missing content.
Installation
Usage
import {
Lockup, LockupMedia, LockupEyebrow, LockupTitle, LockupDescription, LockupActions,
} from "@/components/ui/lockup"Examples
Default
The empty state: an icon, what happened, and a way forward. Always give it LockupActions; without one the message is a dead end.
No matching invoices
Nothing came back for that search in the last 90 days. Try a shorter phrase, or widen the date range.
Eyebrow
The kicker that names a section, above the title. The caps, tracking, weight, and size all live in the component, so nothing is set at the call site. It is muted by default; pass className="text-accent" for the marketing treatment. It adds no gap of its own.
Why super templates?
Everything your team needs to ship a page, already decided
Layout, type, and spacing come pre-resolved, so the only thing left to choose is the words.
Sizes
One message at all five sizes. Only the media, type, and gaps change; the parts and their order never do. section and page both head real content rather than standing in for missing content, so neither pads itself.
Your inbox is clear
Nothing needs your attention right now.
Your inbox is clear
Nothing needs your attention right now.
Your inbox is clear
Nothing needs your attention right now.
Your inbox is clear
Nothing needs your attention right now.
Your inbox is clear
Nothing needs your attention right now.
Alignment
The same lockup inside a card, left-aligned and centered. Left reads better when the card is narrow enough that centered text leaves ragged edges on both sides. align defaults to center, so the second card passes nothing.
No shared files
Files people share with you land here.
No shared files
Files people share with you land here.
Tones
Tone tints the media chip and its glyph. It never changes the box, the type, or the layout.
Nothing here yet
Neutral reads as a resting state, not a problem.
No results
Accent is the default: an ordinary, expected outcome.
Import finished
Success confirms work that just completed.
Connection lost
Destructive is for a failure the reader has to act on.
Full page
The lg size, sized for a route that has nothing else on it. Set level={1} so the message is the page's heading, and step the buttons up to size="lg" to match the display-size title.
The run failed
The run was stopped, usually because the dev server restarted. Your transcript is saved.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| sizeLockup | "sm" | "md" | "section" | "page" | "lg" | "md" | Scales the media, type, gaps, and the description's width cap. Every part reads this off the root, so you set it once. section is for heading a page section; page is a page's own headline sharing the viewport with other content; lg is a full, empty page. |
| alignLockup | "center" | "start" | "center" | start left-aligns the text and the action row. Uses logical alignment, so it flips under RTL. |
| toneLockupMedia | "neutral" | "accent" | "success" | "destructive" | "accent" | Colors the glyph, and the chip behind it when variant is tinted. Nothing else changes. |
| variantLockupMedia | "tinted" | "bare" | "tinted" | bare drops the chip and steps the glyph up one size, since an icon with nothing behind it needs more size to hold the same weight. Use it in tight containers, or when the media is an avatar or an image. |
| levelLockupTitle | 1 | 2 | 3 | 4 | 5 | 6 | 2 | Heading rank. A full-page lockup is usually the page's h1; one inside a card is usually an h3. |
| classNameLockupEyebrow | string | - | The kicker above the title, and the system's one sanctioned uppercase surface in page content. Muted by default so it stays subordinate to the title; pass text-accent for the marketing treatment. Sized for section, page, and lg. |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
/**
* Lockup: the stack of media, title, description, and actions that stands in
* for content. Empty results, an error, a success screen, an onboarding prompt.
*
* Size is picked by the CONTAINER, not by taste:
* sm - inside a card, a table body, a small panel
* md - a modal, a side panel, an in-page empty region
* section - the heading stack that INTRODUCES a page section
* page - a page's own headline sharing the viewport with other content:
* a split-layout hero, a landing header
* lg - a full page: an empty viewport the block floats in alone
*
* Alignment flips with size in every design system that documents this: large
* spaces center the whole block, small tiles and side panels left-align the
* text. `align="start"` is that second case.
*
* Every part reads its scale off the root's `data-size`, so the component ships
* with no context and no client boundary: a server page with zero rows can
* render it directly.
*/
type LockupSize = "sm" | "md" | "section" | "page" | "lg"
type LockupAlign = "center" | "start"
type LockupTone = "neutral" | "accent" | "success" | "destructive"
// @use-when a full-page or in-panel prompt standing in for content: an empty
// result, an error, a success screen, an onboarding or sign-in gate. It owns
// the eyebrow, title, description and action rhythm, so no call site sets a
// gap.
function Lockup({
size = "md",
align = "center",
className,
...props
}: React.ComponentProps<"div"> & {
/** Match this to the container, not to the importance of the message. */
size?: LockupSize
/** `center` for full pages and modals, `start` inside cards and side panels. */
align?: LockupAlign
}) {
return (
<div
data-slot="lockup"
data-size={size}
data-align={align}
className={cn(
"group/lockup flex flex-col px-4",
// text-start, not text-left, so the component survives RTL.
"data-[align=center]:items-center data-[align=center]:text-center",
"data-[align=start]:items-start data-[align=start]:text-start",
// The root gap is the TITLE-to-DESCRIPTION step, the tightest pair in
// the stack. Media and actions are optional, so each owns the extra gap
// it introduces rather than the root guessing which parts are present.
// The empty-state sizes pad themselves because they float in an empty
// container. `section` heads real content, and the section around it
// owns that rhythm, so it ships no vertical padding. (Also practical:
// a data-size py outranks a plain pb-0 from className, so shipping one
// would be unoverridable.) `page` shares that same reasoning: it heads
// a page's own content sharing the viewport with other things, so its
// container owns the rhythm too.
"data-[size=sm]:gap-1 data-[size=sm]:py-8",
"data-[size=md]:gap-1 data-[size=md]:py-12",
"data-[size=section]:gap-2",
"data-[size=page]:gap-2",
"data-[size=lg]:gap-2 data-[size=lg]:py-16",
className
)}
{...props}
/>
)
}
/**
* The icon, avatar, or image above the title.
*
* `tinted` (default) is the icon inside a filled chip. `bare` drops the chip
* and bumps the glyph a step, since an icon with nothing behind it needs more
* size to hold the same weight. Pass an avatar or an image with `bare`.
*
* Tone changes colour only. It never changes the box, the type, or the layout.
*/
function LockupMedia({
tone = "accent",
variant = "tinted",
className,
...props
}: React.ComponentProps<"div"> & {
tone?: LockupTone
variant?: "tinted" | "bare"
}) {
return (
<div
data-slot="lockup-media"
data-tone={tone}
data-variant={variant}
className={cn(
"flex shrink-0 items-center justify-center [&_svg]:shrink-0",
// The gap this part adds on top of the root's, so the media reads as
// its own tier rather than as the first line of the copy.
"group-data-[size=sm]/lockup:mb-0.5",
"group-data-[size=md]/lockup:mb-1",
"group-data-[size=section]/lockup:mb-1.5",
"group-data-[size=page]/lockup:mb-2",
"group-data-[size=lg]/lockup:mb-2",
// The chip. Sized only when tinted; `bare` has no box to size.
"data-[variant=tinted]:rounded-full",
"group-data-[size=sm]/lockup:data-[variant=tinted]:size-8",
"group-data-[size=md]/lockup:data-[variant=tinted]:size-12",
"group-data-[size=section]/lockup:data-[variant=tinted]:size-14",
"group-data-[size=page]/lockup:data-[variant=tinted]:size-16",
"group-data-[size=lg]/lockup:data-[variant=tinted]:size-16",
// Glyph. `:not([class*='size-'])` leaves the door open for a caller
// who passes their own size on the icon.
"group-data-[size=sm]/lockup:data-[variant=tinted]:[&_svg:not([class*='size-'])]:size-4",
"group-data-[size=md]/lockup:data-[variant=tinted]:[&_svg:not([class*='size-'])]:size-5",
"group-data-[size=section]/lockup:data-[variant=tinted]:[&_svg:not([class*='size-'])]:size-6",
"group-data-[size=page]/lockup:data-[variant=tinted]:[&_svg:not([class*='size-'])]:size-7",
"group-data-[size=lg]/lockup:data-[variant=tinted]:[&_svg:not([class*='size-'])]:size-7",
"group-data-[size=sm]/lockup:data-[variant=bare]:[&_svg:not([class*='size-'])]:size-5",
"group-data-[size=md]/lockup:data-[variant=bare]:[&_svg:not([class*='size-'])]:size-6",
"group-data-[size=section]/lockup:data-[variant=bare]:[&_svg:not([class*='size-'])]:size-7",
"group-data-[size=page]/lockup:data-[variant=bare]:[&_svg:not([class*='size-'])]:size-8",
"group-data-[size=lg]/lockup:data-[variant=bare]:[&_svg:not([class*='size-'])]:size-8",
// Tone. Alpha on the primitive scale (not on a semantic token), so a
// consumer who re-themes `--color-accent-500` gets a matching chip for
// free. Dark mode lifts the fill a step because the same wash reads
// weaker against a dark surface. Neutral lifts a step further still
// (neutral-800, matching dark `--muted`): dark `--card` is itself
// neutral-900, so a neutral chip needs to clear both the page
// background AND a card it's sitting on, not just the page.
"data-[tone=neutral]:text-muted-foreground",
"data-[tone=accent]:text-accent",
"data-[tone=success]:text-success",
"data-[tone=destructive]:text-destructive",
"data-[variant=tinted]:data-[tone=neutral]:bg-neutral-100 dark:data-[variant=tinted]:data-[tone=neutral]:bg-neutral-800",
"data-[variant=tinted]:data-[tone=accent]:bg-accent-100 dark:data-[variant=tinted]:data-[tone=accent]:bg-accent-800",
"data-[variant=tinted]:data-[tone=success]:bg-success-100 dark:data-[variant=tinted]:data-[tone=success]:bg-success-900",
"data-[variant=tinted]:data-[tone=destructive]:bg-destructive-100 dark:data-[variant=tinted]:data-[tone=destructive]:bg-destructive-900",
className
)}
{...props}
/>
)
}
/**
* The kicker above the title: the small caps label that names the section.
* "WHY SUPER TEMPLATES?" over the headline.
*
* THE SYSTEM'S ONE SANCTIONED UPPERCASE SURFACE in page content, which is why
* the treatment lives here and not in a utility every consumer retypes. Caps
* need tracking to stay legible (`tracking-wide`, the kit's 0.08em, the same
* token the table header and nav group label already use) and they need to be
* small, because caps at size read as shouting rather than as a label.
*
* Muted, not accent. The eyebrow is a label for the title, so it must not
* outrank it; colour is the axis that keeps it subordinate while caps and
* tracking make it a distinct register. A marketing page that wants the
* accent-coloured version passes `className="text-accent"`, which is a
* deliberate call rather than the default.
*
* It introduces NO extra gap, and that is a decision rather than an omission.
* The root gap is already the tightest pair in the stack, and an eyebrow
* belongs TO the title the way a title belongs to its description; pushing it
* further away would read as a separate line of copy. `LockupMedia` and
* `LockupActions` add margin because they are separate tiers. This is not.
*
* Sized for `section`, `page`, and `lg`, which is where a kicker earns its
* place. It still renders at `sm` and `md` rather than refusing to, because a
* component that silently drops content is worse than one used slightly
* off-label, but a caps label above a 14px card title is noise: reach for it
* when the lockup is introducing a page or a section.
*/
function LockupEyebrow({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="lockup-eyebrow"
className={cn(
"font-semibold uppercase tracking-wide text-muted-foreground",
"group-data-[size=sm]/lockup:text-2xs",
"group-data-[size=md]/lockup:text-2xs",
"group-data-[size=section]/lockup:text-2xs",
"group-data-[size=page]/lockup:text-1xs",
"group-data-[size=lg]/lockup:text-1xs",
className
)}
{...props}
/>
)
}
/**
* The headline. Renders an `h2`; set `level` when the page's outline needs a
* different rank (a full-page error is usually the `h1`, a lockup inside a card
* is usually an `h3`).
*/
function LockupTitle({
level = 2,
className,
...props
}: React.ComponentProps<"h2"> & {
level?: 1 | 2 | 3 | 4 | 5 | 6
}) {
const Heading = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
return (
<Heading
data-slot="lockup-title"
className={cn(
// A heading wraps short: it breaks into lines rather than stretching
// across the page. text-balance evens those lines out.
// Leading is overridden ONLY to tighten: sm/md/section/page sit above
// the display metric (text-sm ships 1.5, text-xl/text-2xl/text-3xl
// 1.35/1.35/1.25) so all four drop to leading-tight; lg keeps
// text-display's own 1.22, which is already tighter than any leading
// token, so it gets no override.
//
// THE MEASURE SCALES WITH THE SIZE, like the type and the leading do.
// It shipped as a flat `measure-heading` (24ch) at every size, which is
// a DISPLAY measure: right for a three-word hero, but it strangles a
// full-sentence section title into four or five ragged lines. The
// narrower the type, the more characters a line can hold before the
// eye's return sweep gets long, so only `lg` keeps the tight cap and
// everything under it, including `page`, reads `measure-heading-relaxed`
// (32ch). The token itself is deliberately NOT widened: it is the
// heading measure for the whole system and moving it would move every
// heading.
// No `text-balance` utility here, on purpose: A CAP NEVER TRAVELS
// ALONE, and the pairing lives in CSS. `.max-w-measure-heading` and
// `.max-w-measure-heading-relaxed` both carry `text-wrap: balance` in
// `@layer components`, so the cap that creates the orphan risk brings
// its own fix and no call site retypes it.
//
// That is also the trap this hit. Adding the `-relaxed` cap without
// adding its rule to that layer silently dropped balance from every
// size but `lg`, and nothing catches it: the class is real, the width
// is right, and only the line breaks change. A new cap is only half a
// token until it is paired.
// NO `cap-trim` here, deliberately. The trim only travels with a gap
// that was tuned for a trimmed box, and Lockup's title-to-description
// step is four FROZEN per-size classes on the root (`gap-1`/`gap-2`),
// hand-tuned against four different type steps rather than reading one
// rung. Trimming without re-tuning those four ate the title's
// below-baseline space, which is proportional to type size: roughly
// 3.7px at `sm`, 5.3 at `md`, 6.4 at `section` and 9.0 at `lg`, so the
// 36px hero lost more than half its gap (17px painted down to 8) and
// the titles collided with their descriptions. Shipped that way for one
// commit; do not re-add the class on its own. Making Lockup honest
// means giving it a size-ramped RUNG first, the way PageHeader has
// `heading-sm|md|lg`, and only then trimming.
"font-semibold max-w-measure-heading-relaxed",
"group-data-[size=sm]/lockup:text-sm group-data-[size=sm]/lockup:leading-tight",
"group-data-[size=md]/lockup:text-xl group-data-[size=md]/lockup:leading-tight",
"group-data-[size=section]/lockup:text-2xl group-data-[size=section]/lockup:leading-tight group-data-[size=section]/lockup:tracking-tight",
"group-data-[size=page]/lockup:text-3xl group-data-[size=page]/lockup:leading-tight group-data-[size=page]/lockup:tracking-tight",
"group-data-[size=lg]/lockup:text-display group-data-[size=lg]/lockup:tracking-tight group-data-[size=lg]/lockup:max-w-measure-heading",
className
)}
{...props}
/>
)
}
/**
* The supporting line. Capped so it wraps to two or three short lines: the caps
* below land at roughly 51 to 55 characters at each size's type, deliberately
* narrower than the 68ch reading measure, because centred copy fragments if you
* let it run the full measure. leading-snug (1.35), not leading-normal: this is
* short supporting copy sitting under a heading, not a reading column, so it
* pairs tight with the title rather than spacing out like body prose.
*/
function LockupDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="lockup-description"
className={cn(
"text-muted-foreground leading-snug text-pretty",
"group-data-[size=sm]/lockup:text-1xs group-data-[size=sm]/lockup:max-w-sm",
"group-data-[size=md]/lockup:text-sm group-data-[size=md]/lockup:max-w-md",
"group-data-[size=section]/lockup:text-base group-data-[size=section]/lockup:max-w-lg",
"group-data-[size=page]/lockup:text-lg group-data-[size=page]/lockup:max-w-lg",
"group-data-[size=lg]/lockup:text-lg group-data-[size=lg]/lockup:max-w-lg",
className
)}
{...props}
/>
)
}
/**
* The action row: one primary button, optionally a secondary beside it. Wraps
* on narrow screens rather than overflowing.
*/
function LockupActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="lockup-actions"
className={cn(
"flex flex-wrap items-center",
"group-data-[align=center]/lockup:justify-center",
"group-data-[align=start]/lockup:justify-start",
// Same rule as the media: the gap this part adds sits on the part.
"group-data-[size=sm]/lockup:mt-2 group-data-[size=sm]/lockup:gap-2",
"group-data-[size=md]/lockup:mt-3 group-data-[size=md]/lockup:gap-2",
"group-data-[size=section]/lockup:mt-4 group-data-[size=section]/lockup:gap-2",
"group-data-[size=page]/lockup:mt-4 group-data-[size=page]/lockup:gap-3",
"group-data-[size=lg]/lockup:mt-4 group-data-[size=lg]/lockup:gap-3",
className
)}
{...props}
/>
)
}
export {
Lockup,
LockupMedia,
LockupEyebrow,
LockupTitle,
LockupDescription,
LockupActions,
}