Lockup
A centered stack of icon, heading, description, and actions that stands in for missing content.
Installation
Usage
import {
Lockup, LockupMedia, LockupTitle, LockupDescription, LockupActions,
} from "@/components/ui/lockup"Examples
Default
No matching invoices
Nothing came back for that search in the last 90 days. Try a shorter phrase, or widen the date range.
Sizes
One message at all three sizes. Only the media, type, and gaps change; the parts and their order never do.
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.
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" | "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. |
| 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. |
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, a page section
* lg - a full page
*
* 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" | "lg"
type LockupAlign = "center" | "start"
type LockupTone = "neutral" | "accent" | "success" | "destructive"
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.
"data-[size=sm]:gap-1 data-[size=sm]:py-8",
"data-[size=md]:gap-1 data-[size=md]:py-12",
"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=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=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=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=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.
"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-900",
"data-[variant=tinted]:data-[tone=accent]:bg-accent-100 dark:data-[variant=tinted]:data-[tone=accent]:bg-accent-900",
"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 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 sit above the display
// metric (text-sm ships 1.5, text-xl 1.35) so both 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.
"font-semibold max-w-measure-heading",
"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=lg]/lockup:text-display group-data-[size=lg]/lockup:tracking-tight",
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=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=lg]/lockup:mt-4 group-data-[size=lg]/lockup:gap-3",
className
)}
{...props}
/>
)
}
export { Lockup, LockupMedia, LockupTitle, LockupDescription, LockupActions }