Elevation
Hairline rings and soft layered shadows that lift surfaces without heavy borders.
Structure from hairlines, elevation from one shadow#
Canon is a hybrid. Hairline borders divide a surface into parts; a single composite shadow lifts the surface off the plane. The two never stand in for each other.
Northwind Freight
Supplier since March 2024
Three invoices are in Friday's payment run. The W-9 on file expires in December.
- Next payment
- Fri, Oct 2
- Method
- ACH
- Lift. The card's edge and its depth come from
shadow-border: a 1px transparent ink ring and two soft drops with negative spread. There is no CSS border on the card. - Structure. A hairline
border-b border-bordersplits the header from the body. Borders are for table rules, header bottoms and section splits. - Well. A sub-region is a Well Gray tint at 70% with 10px corners (
rounded-[10px] bg-muted/70), never a second card.
In light, every shadow is tinted with the ink hue, oklch(0.2 0.02 265), so depth reads as a cooler shade of the plane rather than gray smudge. In dark the drops turn black and the ring becomes a faint white hairline, because a dark shadow on a dark plane can't draw an edge by itself. There are no hard or offset shadows anywhere.
The shadow vocabulary#
Eight shadows, each with one job. Six are tokens in globals.css; the primary inset and the pinned edge live in the one component that uses them.
Every shadow in both themes
Light and dark side by side. In light, cards and popovers are the same white as the plane and separate only through the shadow.
Light
- Hairline liftshadow-border
- Hairline lift, hovershadow-border-hover
- Overlayshadow-md ring-1
- Dialogshadow-lg ring-1
- Floating barshadow-popover
- Controlshadow-xs
- Primary insetbutton default
- Pinned edgedata grid
Dark
- Hairline liftshadow-border
- Hairline lift, hovershadow-border-hover
- Overlayshadow-md ring-1
- Dialogshadow-lg ring-1
- Floating barshadow-popover
- Controlshadow-xs
- Primary insetbutton default
- Pinned edgedata grid
import { cn } from "@oration/canon/lib/utils";export function ShadowSpecimens() { const specimens = [ { name: "Hairline lift", token: "shadow-border", className: "h-16 rounded-xl bg-card shadow-border", }, { name: "Hairline lift, hover", token: "shadow-border-hover", className: "h-16 rounded-xl bg-card shadow-border-hover", }, { name: "Overlay", token: "shadow-md ring-1", className: "h-16 rounded-lg bg-popover shadow-md ring-1 ring-foreground/10", }, { name: "Dialog", token: "shadow-lg ring-1", className: "h-16 rounded-xl bg-popover shadow-lg ring-1 ring-foreground/10", }, { name: "Floating bar", token: "shadow-popover", className: "h-16 rounded-xl bg-popover shadow-popover", }, { name: "Control", token: "shadow-xs", className: "h-8 rounded-lg border border-border bg-background shadow-xs dark:border-input dark:bg-input/30", }, { name: "Primary inset", token: "button default", className: "h-8 rounded-lg bg-primary shadow-[inset_0_1px_0_oklch(1_0_0/0.14),0_1px_2px_-1px_oklch(0.2_0.1_272/0.4)]", }, { name: "Pinned edge", token: "data grid", className: "h-16 rounded-l-md bg-background shadow-[1px_0_0_var(--border),8px_0_12px_-8px_oklch(0_0_0/0.18)] dark:shadow-[1px_0_0_var(--border),8px_0_12px_-8px_oklch(0_0_0/0.6)]", }, ]; return ( <div className="grid w-full min-w-0 gap-px overflow-hidden rounded-[10px] bg-border lg:grid-cols-2"> {(["light", "dark"] as const).map((theme) => ( <div key={theme} className={cn( theme, "flex min-w-0 flex-col gap-4 bg-background p-5 text-foreground", )} > <p className="text-xs text-muted-foreground"> {theme === "light" ? "Light" : "Dark"} </p> <ul className="grid grid-cols-2 gap-x-5 gap-y-6 sm:grid-cols-4"> {specimens.map((specimen) => ( <li key={specimen.name} className="flex min-w-0 flex-col justify-end gap-2" > <div aria-hidden="true" className={specimen.className} /> <div className="flex min-w-0 flex-col"> <span className="truncate text-xs font-medium text-foreground"> {specimen.name} </span> <span className="truncate font-mono text-2xs text-muted-foreground"> {specimen.token} </span> </div> </li> ))} </ul> </div> ))} </div> );}| Shadow | Utility | Value (light, dark) | Used for |
|---|---|---|---|
| Hairline lift | shadow-border | 0 0 0 1px oklch(0.2 0.02 265 / 0.07), 0 1px 2px -1px oklch(0.2 0.02 265 / 0.07), 0 2px 4px -2px oklch(0.2 0.02 265 / 0.04)0 0 0 1px oklch(1 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.4) | Cards, panels, kanban cards, flow nodes, canvas controls, edge label chips and the sidebar search field. |
| Hairline lift, hover | shadow-border-hover | 0 0 0 1px oklch(0.2 0.02 265 / 0.11), 0 1px 2px -1px oklch(0.2 0.02 265 / 0.08), 0 4px 10px -4px oklch(0.2 0.02 265 / 0.08)0 0 0 1px oklch(1 0 0 / 0.11), 0 2px 6px -2px oklch(0 0 0 / 0.5) | Hover on interactive cards, the expanded approval, a card being dragged. |
| Overlay | shadow-md ring-1 ring-foreground/10 | 0 2px 4px -2px oklch(0.2 0.02 265 / 0.06), 0 6px 16px -4px oklch(0.2 0.02 265 / 0.08)0 2px 4px -2px oklch(0 0 0 / 0.4), 0 8px 18px -4px oklch(0 0 0 / 0.4) | Menus, popovers, selects, comboboxes and hover cards. |
| Dialog | shadow-lg ring-1 ring-foreground/10 | 0 4px 8px -4px oklch(0.2 0.02 265 / 0.06), 0 16px 40px -8px oklch(0.2 0.02 265 / 0.16)0 4px 8px -4px oklch(0 0 0 / 0.4), 0 18px 44px -8px oklch(0 0 0 / 0.55) | Dialogs, alert dialogs and sheets, always over a scrim. |
| Floating bar | shadow-popover | 0 0 0 1px oklch(0.2 0.02 265 / 0.08), 0 4px 8px -4px oklch(0.2 0.02 265 / 0.08), 0 12px 32px -6px oklch(0.2 0.02 265 / 0.14)0 0 0 1px oklch(1 0 0 / 0.09), 0 4px 8px -4px oklch(0 0 0 / 0.4), 0 14px 36px -6px oklch(0 0 0 / 0.55) | The bulk Action bar, the floating save bar and the call pill. |
| Control | shadow-xs | 0 1px 2px -1px oklch(0.2 0.02 265 / 0.08)0 1px 2px -1px oklch(0 0 0 / 0.4) | Outline buttons only. |
| Primary inset | Button default variant | inset 0 1px 0 oklch(1 0 0 / 0.14), 0 1px 2px -1px oklch(0.2 0.1 272 / 0.4) | The filled indigo Button, and nothing else. |
| Pinned edge | Data grid pinned column | 1px 0 0 var(--border), 8px 0 12px -8px oklch(0 0 0 / 0.18) | The last pinned Data grid column, only while the grid is scrolled sideways. 0.6 alpha in dark. |
:root, .light { --elevation-xs: 0 1px 2px -1px oklch(0.2 0.02 265 / 0.08); --elevation-sm: 0 1px 2px -1px oklch(0.2 0.02 265 / 0.08), 0 2px 4px -2px oklch(0.2 0.02 265 / 0.04); --elevation-md: 0 2px 4px -2px oklch(0.2 0.02 265 / 0.06), 0 6px 16px -4px oklch(0.2 0.02 265 / 0.08); --elevation-lg: 0 4px 8px -4px oklch(0.2 0.02 265 / 0.06), 0 16px 40px -8px oklch(0.2 0.02 265 / 0.16); --elevation-border: 0 0 0 1px oklch(0.2 0.02 265 / 0.07), 0 1px 2px -1px oklch(0.2 0.02 265 / 0.07), 0 2px 4px -2px oklch(0.2 0.02 265 / 0.04); --elevation-border-hover: 0 0 0 1px oklch(0.2 0.02 265 / 0.11), 0 1px 2px -1px oklch(0.2 0.02 265 / 0.08), 0 4px 10px -4px oklch(0.2 0.02 265 / 0.08); --elevation-popover: 0 0 0 1px oklch(0.2 0.02 265 / 0.08), 0 4px 8px -4px oklch(0.2 0.02 265 / 0.08), 0 12px 32px -6px oklch(0.2 0.02 265 / 0.14);}.dark { --elevation-xs: 0 1px 2px -1px oklch(0 0 0 / 0.4); --elevation-sm: 0 1px 2px -1px oklch(0 0 0 / 0.4), 0 2px 4px -2px oklch(0 0 0 / 0.3); --elevation-md: 0 2px 4px -2px oklch(0 0 0 / 0.4), 0 8px 18px -4px oklch(0 0 0 / 0.4); --elevation-lg: 0 4px 8px -4px oklch(0 0 0 / 0.4), 0 18px 44px -8px oklch(0 0 0 / 0.55); --elevation-border: 0 0 0 1px oklch(1 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.4); --elevation-border-hover: 0 0 0 1px oklch(1 0 0 / 0.11), 0 2px 6px -2px oklch(0 0 0 / 0.5); --elevation-popover: 0 0 0 1px oklch(1 0 0 / 0.09), 0 4px 8px -4px oklch(0 0 0 / 0.4), 0 14px 36px -6px oklch(0 0 0 / 0.55);}// Raised surfaces: one composite shadow, no CSS border<div className="rounded-xl bg-card p-4 shadow-border">…</div>// Overlays: the overlay shadow plus a 1px ink ring at 10%<div className="rounded-lg bg-popover p-1 shadow-md ring-1 ring-foreground/10">…</div>// Dialogs and sheets: the dialog shadow and the same ring, over a scrim<div className="fixed inset-0 z-50 bg-black/25 dark:bg-black/55" /><div className="rounded-xl bg-popover p-4 shadow-lg ring-1 ring-foreground/10">…</div>The tokens are exposed as Tailwind shadows through @theme inline: --shadow-border reads --elevation-border, and so on, so shadow-border swaps with the theme without a dark: variant. shadow-sm also exists but has no role in the vocabulary; reach for shadow-border instead.
Where components drift
Card draws a flat ring-1 ring-foreground/10 with no drop; product cards write shadow-border by hand. Toast and Sheet pair a CSS border with shadow-lg, against the Hairline-and-Lift Rule. Submenus use the dialog shadow where DESIGN.md gives them the overlay shadow. The pinned edge is a literal oklch(0 0 0 / 0.18), not a token. Follow DESIGN.md until the components change.
Hover lift#
An interactive card deepens from the hairline lift to the hover lift. Only the shadow moves; the card doesn't grow or jump.
Interactive cards
Hover a supplier. The ring darkens from 7% to 11% and the lower drop spreads, over 150ms on the house ease-out. Click to open it.
import { toast } from "@oration/canon/components/toast";export function HoverLift() { const suppliers = [ { name: "Northwind Freight", detail: "3 invoices in Friday's run" }, { name: "Halcyon", detail: "W-9 expires in December" }, { name: "Orchard Street", detail: "Remittance sent Sep 25" }, ]; return ( <ul className="grid w-full max-w-2xl gap-4 sm:grid-cols-3"> {suppliers.map((supplier) => ( <li key={supplier.name}> <button type="button" onClick={() => toast.add({ title: `Opened ${supplier.name}`, description: supplier.detail, }) } className="flex w-full flex-col gap-1 rounded-xl bg-card p-4 text-left shadow-border transition-shadow duration-150 ease-out outline-none hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <span className="text-13 font-medium text-foreground"> {supplier.name} </span> <span className="text-xs text-muted-foreground"> {supplier.detail} </span> </button> </li> ))} </ul> );}- Use
transition-shadow duration-150 ease-out hover:shadow-border-hover. DESIGN.md allows 150 to 200ms; 150ms is the default. - Don't translate cards up on hover or scale them. Lift is a shadow change, not a movement.
- Static cards don't take the hover lift. If nothing happens on click, nothing changes on hover.
- A card being dragged and the expanded approval in a stack hold the hover lift while they're in that state.
- Focus takes the 3px ring at 40%, as every control does. See Interaction states.
<Link href={`/suppliers/${supplier.id}`} className="flex flex-col gap-1 rounded-xl bg-card p-4 shadow-border transition-shadow duration-150 ease-out hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40"> …</Link>The surface ladder#
Four levels, from the plane up. Each level up takes a deeper shadow; in dark each also steps up in lightness, because shadows barely show on a dark plane.
Plane, card, popover and dialog
Stacked in their order of elevation. Switch the preview to dark to see the fills separate.
Light
Dark
import { cn } from "@oration/canon/lib/utils";export function SurfaceLadder() { const levels = [ { name: "Plane", token: "--background", className: "bg-background" }, { name: "Card", token: "--card", className: "bg-card shadow-border" }, { name: "Popover", token: "--popover", className: "bg-popover shadow-md ring-1 ring-foreground/10", }, { name: "Dialog", token: "--popover", className: "bg-popover shadow-lg ring-1 ring-foreground/10", }, ]; return ( <div className="grid w-full min-w-0 gap-px overflow-hidden rounded-[10px] bg-border md:grid-cols-2"> {(["light", "dark"] as const).map((theme) => ( <div key={theme} className={cn( theme, "relative bg-background p-5 text-foreground", )} > <p className="mb-4 text-xs text-muted-foreground"> {theme === "light" ? "Light" : "Dark"} </p> <div className="relative h-60"> {levels.slice(1).map((level, index) => ( <div key={level.name} className={cn( "absolute flex w-[62%] flex-col justify-between rounded-xl p-3", level.className, )} style={{ top: index * 52, left: `${index * 16}%`, height: 132, }} > <span className="text-13 font-medium text-foreground"> {level.name} </span> <span className="font-mono text-2xs text-muted-foreground"> {level.token} </span> </div> ))} <span className="absolute right-0 bottom-0 font-mono text-2xs text-muted-foreground"> Plane, --background </span> </div> </div> ))} </div> );}| Level | Fill | Fill value (light, dark) | Shadow | Corners |
|---|---|---|---|---|
| Plane | bg-background | oklch(1 0 0)oklch(0.178 0.005 265) | None. The content plane, table cells, the app header and sticky tab bars. | – |
| Card | bg-card | oklch(1 0 0)oklch(0.205 0.006 265) | shadow-border, deepening to shadow-border-hover when interactive. | 12px |
| Popover | bg-popover | oklch(1 0 0)oklch(0.225 0.007 265) | shadow-md with ring-1 ring-foreground/10. No scrim. | 10px |
| Dialog | bg-popover | oklch(1 0 0)oklch(0.225 0.007 265) | shadow-lg with the same ring, over the scrim. | 12px |
- The sidebar is not on the ladder. It is a separate layer in Cool Rail (
bg-sidebar) beside the plane, with no shadow of its own. - Don't skip a level to make something stand out. A card that needs more attention needs better content, not a dialog shadow.
- A surface sits on the level below it, never two up. A popover can open over a dialog; a card never sits inside a card.
Scrims#
Modal surfaces dim the page behind them with plain black: 25% in light, 55% in dark. Nothing blurs.
The dialog scrim in each theme
bg-black/25 dark:bg-black/55, fading in over 200ms with the dialog.
- Friday, Oct 248 suppliers
- Friday, Oct 948 suppliers
- Friday, Oct 1648 suppliers
Schedule payment run
Black scrim at 25%
- Friday, Oct 248 suppliers
- Friday, Oct 948 suppliers
- Friday, Oct 1648 suppliers
Schedule payment run
Black scrim at 55%
import { cn } from "@oration/canon/lib/utils";export function Scrims() { return ( <div className="grid w-full min-w-0 gap-4 md:grid-cols-2"> {(["light", "dark"] as const).map((theme) => ( <div key={theme} className={cn( theme, "relative h-56 overflow-hidden rounded-xl bg-background text-foreground shadow-border", )} > <div className="flex h-10 items-center border-b border-border px-3 text-13 font-medium"> Payment runs </div> <ul className="divide-y divide-border text-13"> {[ "Friday, Oct 2", "Friday, Oct 9", "Friday, Oct 16", ].map((run) => ( <li key={run} className="flex h-9 items-center px-3" > <span className="flex-1">{run}</span> <span className="text-muted-foreground tabular-nums"> 48 suppliers </span> </li> ))} </ul> <div aria-hidden="true" className="absolute inset-0 bg-black/25 dark:bg-black/55" /> <div className="absolute top-1/2 left-1/2 flex w-[70%] -translate-x-1/2 -translate-y-1/2 flex-col gap-1 rounded-xl bg-popover p-4 shadow-lg ring-1 ring-foreground/10"> <p className="text-sm font-medium text-foreground"> Schedule payment run </p> <p className="text-xs text-muted-foreground"> {theme === "light" ? "Black scrim at 25%" : "Black scrim at 55%"} </p> </div> </div> ))} </div> );}| Surface | Scrim | Why |
|---|---|---|
| Dialog, Alert dialog | 25%, 55% dark | Modal. The page behind can't be used until the dialog closes. |
| Sheet, Drawer | 25%, 55% dark | Modal panels. The drawer's scrim also fades with the swipe. |
| Sidebar sheet below 768px | 25%, 55% dark | The sidebar opens over the plane as a sheet. |
| Menus, popovers, selects, tooltips, toasts | None | Non-modal. The page stays readable and a click outside closes them. |
Glass and backdrop-blur decoration are refused outright. A scrim is a flat black wash; it doesn't blur, tint or color the page.
Three surface rules#
These decide how every surface is drawn. Most elevation mistakes in review break one of them.
The Hairline-and-Lift Rule
shadow-border), never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.The Tint Well Rule
The Scroll Edge Rule
Hairline and lift#
One composite shadow carries both the edge and the depth.
shadow-border alone. The 1px ring inside the shadow is the edge.border border-border and shadow-md. The edge doubles up, the drop is too heavy for a resting card, and it no longer matches its neighbors.Tint wells#
A region inside a card is a tint, not another card.
bg-muted/70 well with 10px corners. It groups without adding a level.shadow-border card. Two edges, two corner radii, and the eye reads two objects.Scroll edges#
Clipped content shows its edge, and only while it is clipped.
Faded tabs and a pinned column
The tab row fades its clipped side over 2rem through useOverflowFade. Scroll the table sideways and the pinned Invoice column casts its edge shadow; scroll back and it goes.
| Invoice | Supplier | Due | Status | Amount |
|---|---|---|---|---|
| INV-20931 | Northwind Freight | Oct 2 | Scheduled | $18,240.00 |
| INV-20932 | Halcyon | Oct 2 | Scheduled | $9,612.50 |
| INV-20935 | Orchard Street | Oct 9 | Needs approval | $4,120.00 |
import { useOverflowFade } from "@oration/canon/hooks/use-overflow-fade";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function ScrollEdge() { const fadeRef = useOverflowFade<HTMLElement>('[aria-current="true"]'); const [scrolled, setScrolled] = React.useState(false); const tabs = [ "Overview", "Invoices", "Payments", "Remittances", "Documents", "Contacts", "Activity", "Notes", ]; const [tab, setTab] = React.useState("Invoices"); const invoices = [ ["INV-20931", "Northwind Freight", "Oct 2", "Scheduled", "$18,240.00"], ["INV-20932", "Halcyon", "Oct 2", "Scheduled", "$9,612.50"], ["INV-20935", "Orchard Street", "Oct 9", "Needs approval", "$4,120.00"], ]; return ( <div className="flex w-full max-w-md min-w-0 flex-col gap-4"> <nav ref={fadeRef} data-overflow-fade="" aria-label="Supplier sections" className="no-scrollbar flex gap-1 overflow-x-auto border-b border-border" > {tabs.map((name) => ( <button key={name} type="button" aria-current={tab === name ? "true" : undefined} onClick={() => setTab(name)} className={cn( "relative h-9 shrink-0 rounded-md px-2 text-13 font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/40", tab === name ? "text-foreground after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:bg-foreground" : "text-muted-foreground hover:text-foreground", )} > {name} </button> ))} </nav> <div data-scrolled-x={scrolled || undefined} onScroll={(event) => setScrolled(event.currentTarget.scrollLeft > 0) } className="group/grid overflow-x-auto rounded-xl bg-card shadow-border" > <table className="w-max min-w-full border-collapse text-13"> <thead> <tr className="border-b border-border text-left text-xs text-muted-foreground"> <th className="sticky left-0 z-10 h-8 bg-card px-3 font-medium shadow-[1px_0_0_var(--border)] transition-shadow group-data-[scrolled-x]/grid:shadow-[1px_0_0_var(--border),8px_0_12px_-8px_oklch(0_0_0/0.18)] dark:group-data-[scrolled-x]/grid:shadow-[1px_0_0_var(--border),8px_0_12px_-8px_oklch(0_0_0/0.6)]"> Invoice </th> <th className="px-3 font-medium">Supplier</th> <th className="px-3 font-medium">Due</th> <th className="px-3 font-medium">Status</th> <th className="px-3 text-right font-medium"> Amount </th> </tr> </thead> <tbody> {invoices.map(([id, supplier, due, status, amount]) => ( <tr key={id} className="border-b border-border last:border-b-0" > <td className="sticky left-0 z-10 h-9 bg-card px-3 font-mono text-xs shadow-[1px_0_0_var(--border)] transition-shadow group-data-[scrolled-x]/grid:shadow-[1px_0_0_var(--border),8px_0_12px_-8px_oklch(0_0_0/0.18)] dark:group-data-[scrolled-x]/grid:shadow-[1px_0_0_var(--border),8px_0_12px_-8px_oklch(0_0_0/0.6)]"> {id} </td> <td className="px-3 whitespace-nowrap"> {supplier} </td> <td className="px-3 whitespace-nowrap tabular-nums"> {due} </td> <td className="px-3 whitespace-nowrap"> {status} </td> <td className="px-3 text-right whitespace-nowrap tabular-nums"> {amount} </td> </tr> ))} </tbody> </table> </div> </div> );}- Tab and view rows get
data-overflow-fadeand the useOverflowFade hook, which setsdata-overflow-startanddata-overflow-end. globals.css masks the clipped side. - The pinned edge shadow is keyed on
data-scrolled-x, set only whilescrollLeft > 0. At rest the pinned column has a plain 1px hairline. - Don't cast a permanent shadow on a sticky header or column. A shadow that shows when nothing is hidden behind it is noise.
Layering#
Five z-index steps. Sticky chrome sits low, floating bars above it, and every portaled overlay at the top.
Header, bar and menu in one pane
Scroll the list. Rows pass under the sticky header, the action bar floats over the rows, and the menu sits above both.
- Northwind Freight
- Halcyon
- Orchard Street
- Bellweather Logistics
- Cobalt Office Supply
- Juniper Print Co.
- Meridian Fuel
- Tidewater Packaging
import { cn } from "@oration/canon/lib/utils";import { CheckIcon, MoreHorizontalIcon, Trash2Icon, XIcon } from "lucide-react";export function Layers() { const rows = [ "Northwind Freight", "Halcyon", "Orchard Street", "Bellweather Logistics", "Cobalt Office Supply", "Juniper Print Co.", "Meridian Fuel", "Tidewater Packaging", ]; return ( <div className="relative h-80 w-full max-w-lg overflow-hidden rounded-xl bg-background text-left shadow-border"> <div className="h-full overflow-y-auto"> <div className="sticky top-0 z-30 flex h-11 items-center justify-between border-b border-border bg-background px-3"> <span className="text-13 font-medium">Suppliers</span> <span className="rounded-sm bg-muted px-1.5 font-mono text-2xs text-muted-foreground"> z-30 </span> </div> <ul className="pb-20"> {rows.map((row, index) => ( <li key={row} className="flex h-9 items-center gap-2 border-b border-border px-3 text-13" > <span className={cn( "flex size-4 items-center justify-center rounded-[4px] border", index < 2 ? "border-primary bg-primary text-primary-foreground" : "border-input", )} > {index < 2 ? ( <CheckIcon aria-hidden="true" className="size-3" /> ) : null} </span> {row} </li> ))} </ul> </div> <div className="absolute top-[4.5rem] right-3 z-50 flex w-44 flex-col rounded-lg bg-popover p-1 text-13 shadow-md ring-1 ring-foreground/10"> <span className="flex h-7 items-center justify-between rounded-md bg-accent px-1.5"> Open supplier <span className="font-mono text-2xs text-muted-foreground"> z-50 </span> </span> <span className="flex h-7 items-center rounded-md px-1.5"> Request W-9 </span> <span className="flex h-7 items-center gap-1.5 rounded-md px-1.5 text-destructive"> <Trash2Icon aria-hidden="true" className="size-4" /> Remove </span> </div> <div className="absolute bottom-4 left-1/2 z-40 flex -translate-x-1/2 items-center gap-1 rounded-xl bg-popover p-1.5 shadow-popover"> <span className="px-2 text-13 font-medium tabular-nums"> 2 selected </span> <span className="h-4 w-px bg-border" /> <span className="flex h-7 items-center gap-1 rounded-md px-2 text-13"> <MoreHorizontalIcon aria-hidden="true" className="size-4" /> Actions </span> <span className="flex size-7 items-center justify-center rounded-md text-muted-foreground"> <XIcon aria-hidden="true" className="size-4" /> </span> <span className="pr-1 font-mono text-2xs text-muted-foreground"> z-40 </span> </div> </div> );}| Layer | z-index | What sits there |
|---|---|---|
| Sticky in a pane | z-10 | Pinned grid cells, sticky section headers inside lists and inbox panes, the sidebar's fixed rail. |
| Sticky grid header | z-20 | The Data grid thead, above its own pinned cells, and the Save bar. |
| App header | z-30 | The 48px sticky page header in components/shell/page-header.tsx. Everything in the page scrolls under it. |
| Floating bars and panels | z-40 | The bulk Action bar, the call pill and panel, the copilot panel. |
| Overlays | z-50 | Menus, popovers, selects, comboboxes, hover cards, tooltips, dialogs, sheets and their scrims. All portal to the body. |
- Overlays all share
z-50and stack by DOM order. A menu opened from a dialog portals after it, so it lands on top without a higher number. - Toasts compute their own
z-[calc(1000-var(--toast-index))]so newer toasts sit above older ones, above everything else. - Don't invent
z-[999]. If something is hidden, it is in the wrong layer or missingisolateon its container.