# Elevation

> Five layers from the system's shadow tokens, from the base surface to modals, plus the pop-in and pop-out motion for floating layers.

Utility · [Docs](https://ui.ssntl.app/docs/components/elevation) · `npx shadcn@latest add @ssntl/elevation`

Installs `styles/ssntl/elevation.css`, with `shadow-smooth`. Read this file before you use, change or review it.

## When to use it

- To place every surface on its layer: base, control, dropdown, raised, overlay.

## When not to

- Shadows on static content, or mixing ad-hoc shadows with the levels.

## Usage

```css
/* app/globals.css */
@import "../styles/ssntl/shadow-smooth.css";
@import "../styles/ssntl/elevation.css";

<section class="bg-muted elevation-0">…</section>
<div role="menu" class="elevation-2 data-[state=open]:pop-in">…</div>
<div role="dialog" class="elevation-4">…</div>
```

## Rules for Elevation

- **Base surface.** Static content sits at `elevation-0` and separates by tone, never by shadow.
- **Dropdown layer.** Menus, popovers and toolbars take `elevation-2` (`shadow-float`).
- **Raised card.** A featured or dragged card takes `elevation-3` (float plus lift), above dropdowns.
- **Floating notification and modal depth.** Toasts and dialogs take `elevation-4`, the top layer; a modal adds a scrim.
- `pop-in` grows a layer in from its origin over `--duration-menu` (set `--pop-duration` for modals); `pop-out` leaves faster. Reduced motion keeps the fade only.

## How it is built

Elevation: how far a layer sits above the page, from the system's own shadow tokens. Static content stays on the base surface; only what floats gets a shadow, and the higher it floats, the more it casts.

- `elevation-0`: base surface: cards, sections (tone separates them)
- `elevation-1`: controls resting on a surface (bezel)
- `elevation-2`: dropdowns, popovers, menus, toolbars (float)
- `elevation-3`: raised or dragged cards (float + lift)
- `elevation-4`: toasts and modals, above everything (smooth stack × 3)
- `pop-in, pop-out`: enter and exit for floating layers; set --pop-duration to var(--duration-panel) for modals

Floating layers grow in from their origin and fade out faster than they came in. With reduced motion only the opacity changes.

## UI foundations it applies

- **Base surface** (Layering & elevation): Keep static content flat on the base surface; save shadows for what floats.
- **Dropdown layer** (Layering & elevation): Lift dropdowns and popovers a modest step above the page.
- **Raised card** (Layering & elevation): Lift a featured or dragged card above dropdowns, below overlays.
- **Floating notification** (Layering & elevation): Put toasts on the top layer, above everything on the page.
- **Modal depth** (Layering & elevation): Put modals on the top layer over a scrim.

## Surfaces

- Static content sits at `elevation-0` and separates by tone; only what floats casts a shadow.
- A shadow used as a ring keeps a 1px transparent border, so forced-colors mode still draws an edge.
- Full-height areas use 100dvh, never 100vh; widths use 100%, never 100vw. Reusable components adapt with container queries (@container), not the viewport.
- Content sets the height; tiles in a row stretch to the tallest.
- **Nested surfaces are concentric.** Inner radius = outer radius − padding.

## Overlays

- Focus moves into the layer, stays trapped while it is modal, and returns to the trigger on close.
- Escape closes it; so does a visible close button named for what it closes (Close dialog, Dismiss notification), never “X”.
- **Contain its scroll (overscroll-behavior.** Contain), so scrolling inside never scrolls the page behind.
- A modal is never taller than the screen (100dvh): long content scrolls inside while the title and the actions stay in view.
- A toast with an action or an error stays until it is dismissed; a timed toast lasts at least 5 seconds and pauses on hover and focus. Anything that moves on its own for more than 5 seconds has a pause control.
- **Elevation matches the layer.** Dropdowns `elevation-2`, raised cards `elevation-3`, toasts and modals `elevation-4` (a modal adds a scrim).
- Floating layers enter with `pop-in` from their origin and leave faster with `pop-out`.

## Design tokens only

- Use the system's tokens for every design value: type roles (`text-caption` 13, `text-label` 15, `text-body` 17, `text-lead` 20, `text-heading` 22, `text-title` 32, `text-headline` 48, `text-display` 72), radii (`rounded-xs` 8 … xl 44), spacing steps, colors, shadows and motion. If a value doesn't exist, take the nearest token. Never write text-[15px], rounded-[24px] or bg-[#…]; `pnpm lint` rejects them.
- Type roles already carry line height, tracking and weight. Pick the role; never add `leading-*` or `tracking-*` on top.
- **Nested corners are concentric.** Inner radius = outer radius − padding − border width (`rounded-outer`-* p-concentric-* `rounded-inner`). Past 24px of padding, or with deliberately uneven padding, each layer keeps its own radius token.
- Separate surfaces by tone, not borders. A border is for structure (a divider) or state (an input, a selection).

## Accessibility baseline

- Text meets 4.5:1 against its surface (3:1 at 24px and up, or 18.67px bold); input borders, focus rings and chart marks meet 3:1, in light and dark. Text on a translucent color (`white/80`, /50, color-mix) is measured as rendered over its background; the theme pairs are checked in lint.
- Meaning never rides on color alone: a state carries a word, an icon or a pattern as well.
- Every page fits a 320px screen at 200% zoom with no horizontal scroll; a grid that holds content uses grid-cols-[minmax(0,1fr)] and flex children get min-w-0. Never set maximum-scale=1 or user-scalable=no.
- A focused element is never hidden under sticky chrome: set scroll-padding to the header's height (and to footers or banners at the bottom).
- On a client-side route change, update the title and move focus to the new page's main content.
- **Respect prefers-reduced-motion.** Keep opacity and color, drop movement, scale and blur.
- Content is never truncated without a way to read all of it; long names wrap. A hover-only title tooltip doesn't count as a way back.

## Writing

- Sentence case everywhere; no ALL CAPS except the Eyebrow role.
- One concept, one word, across navigation, headings and buttons.
- Buttons are a verb and its object and say what happens (Delete product, not OK).
- An error says what went wrong and how to fix it, in the user's language.
- **Status copy follows one pattern.** In progress is the verb in -ing (Saving changes…), done names the object and the past tense with no “successfully” (Changes saved), a failure gives the next step, partial results give counts. Cut “Please”.
- A confirmation names the action and the object (Delete Ceramic cup?), never “Are you sure?”. Deleting an account, a workspace or a store asks for its name to be typed.
- Show known requirements as helper text before input, not only as an error after it. Links never say “Click here”, and no two links on a page say “Learn more”.
- Build each sentence as one translatable message, never concatenated around a variable; join lists with Intl.`ListFormat`.
- **Use real characters.** An en dash for ranges (09:30–17:45), × for dimensions, curly quotes, an ellipsis (…).

## Fixing

- Take the cheapest fix that works, in this order: delete (a separator space would carry, an animation on a frequent action, an ARIA attribute a native element makes redundant), use the platform (the native element or control, the browser's focus ring), reuse what the system has (a token, a spacing step, a motion curve), correct the value, and only then add something new.
- Code that adds what a deletion would fix is a problem in itself; fix it by removing.
- A documented convention settles matters of taste, but never excuses a release blocker.

## Worst-case content

- Build and test with the worst real values, not the happy ones: long hyphenated names with diacritics, unbreakable emails and URLs, German compounds, translations about 30% longer, emoji and right-to-left names.
- Unbreakable strings wrap anywhere; nothing pushes its siblings off a row or out of a container.
- Every image has a fallback and a box: a failed avatar shows initials, a frame keeps its ratio, so loading never shifts the layout.
- **Show data as data.** Escape HTML, never render null, undefined or NaN, and round floating point before display.
- **Check the environment as well.** A 280px column, a 2560px screen, 200% zoom, dark mode, `dir="rtl"` and touch.

## Icons

- One icon set (Lucide) at one stroke: 1.6, the Essential Design spec, beside regular and medium text; a heavier stroke only where a small glyph must read (a 16px check). (better-ui proposes rendered strokes of 1.5–2px with `absoluteStrokeWidth`; the system's documented stroke takes precedence as a matter of style.)
- Inline icons are 1em–1.25em of the text beside them.
- Icons are 20px beside labels and 16px in dense controls, at the size they were drawn; a well or a button, not a bigger glyph, gives them room.
- One SVG per icon, colored with `currentColor`; hover, selected and disabled states come from color and opacity, never from separate assets.
- Decorative icons are `aria-hidden`; an icon that is the only content of a control gets its name from the control.

## Light, dark and languages

- Design both themes from the tokens; dark mode is chosen, not inverted. Check every new pair in both.
- Pass every visible and accessible string as a prop or child, so the component works in any language; never hard-code copy inside it.
- Use logical properties (ms-, me-, start-, end-) so layouts mirror in right-to-left languages; `translateX` and background-position need a right-to-left override. Mirror only icons whose meaning follows reading direction (arrows, chevrons), and wrap mixed-direction values in `<bdi>`.
- **Visual order matches DOM order.** No order, row-reverse or grid placement that reorders content.
- Switching themes pauses transitions for the switch, so every surface changes at once.

## Before you ship

- [ ] Every interactive control has an accessible name.
- [ ] Every keyboard-reachable control shows a visible focus indicator.
- [ ] Nothing is reachable by pointer but not by keyboard.
- [ ] Motion and auto-playing content respect prefers-reduced-motion.
- [ ] Nothing is clipped, overlapped or unreachable at 320px width or 200% zoom.
- [ ] Body and control text pass their contrast ratio.
- [ ] No state or meaning is carried by color alone.
- [ ] Destructive actions have a confirmation, an undo, or a distinct treatment.
- [ ] No content is truncated without a way to reach the full value.
- [ ] No content or control hides past a scroll edge or disclosure without a visible cue.
- [ ] Every error names a way to recover.
- [ ] Semantic colors are used for their meaning only (danger never on a safe action).
- [ ] No state change is carried by motion alone.
- [ ] Nothing focused hides under sticky chrome, and overlays contain their own scroll.
- [ ] Worst-case values (long, empty, many, missing, right-to-left) don't break the layout.

## Review with these skills

Every interface built with ssntl/ui is reviewed with the same skills the library is. Install them once and keep them current:

```bash
npx skills@latest add https://github.com/jakubkrehel/skills --skill better-interface
npx skills@latest add jakubkrehel/make-interfaces-feel-better
npx skills@latest add emilkowalski/skills
```

- **better-interface** (Jakub Krehel): The full review: accessibility, layout, writing, typography, color and polish. Its escalation triggers block a release.
- **make-interfaces-feel-better** (Jakub Krehel): The details: concentric radii, optical alignment, press scale, interruptible and subtle motion, hit areas.
- **emilkowalski/skills** (Emil Kowalski): Design engineering: easing, durations, springs and gestures (emil-design-eng, review-animations, animation-performance) and the worst-case values that break a UI (break-ui).

## Related

- [Smooth shadows](shadow-smooth.md): Five-layer shadows that fall off like real light. Size, intensity, tint and a hairline ring.
- [Menu](menu.md): A dropdown menu on elevation 2 whose inset header and items share the panel's curve.
- [Dialog](dialog.md): A modal on the top layer with focus trap, a nested close button and actions at the end.
- [Toast](toast.md): A floating notification you can swipe away: it follows the finger and only leaves on release.

---

Generated from the ssntl/ui source by `scripts/build-rules.mjs`. The whole rulebook is [ssntl-ui.md](ssntl-ui.md); every component's rules are at https://ui.ssntl.app/rules.
