presentations.ai

Design system

Every component the system ships except those held back while they settle, rendered live. View source to copy any snippet β€” what you see is what pai.css produces. Two classes are page chrome and not the system's: .sb-menu pins the dropdown open, and .sb-grid-cell draws the specimen cells. Strip them from anything you copy. The scrim demo is drawn by hand rather than from .pai-scrim, so take that one from the component, not from here.

Naming

Four kinds of class, and every component is built from them in the same order: the component, then what it is, then how big, then what state it is in.

The four

KindFormExampleRule
Component.pai-<component>.pai-avatarOne noun. Always required.
Variant.pai-<component>-<variant>.pai-badge-successWhat it is. Tone, shape, emphasis.
Size.pai-<component>-<size>.pai-switcher-smallsmall Β· medium Β· large. Omitted means medium.
State.is-<state>.is-errorWhat is true right now. Set and removed at runtime.

Variant against state

The distinction decides which form a class takes, and it is the one most often got wrong. A variant is chosen when the screen is built and does not change. A badge is a success badge; it will not become a danger badge. A state is true now and false later β€” a field is in error until it is corrected, a menu is open until it closes. Variants read as part of the component's name; states read as something happening to it.

Buttons

.button-style plus a size and a colour, and optionally a shape. At most two filled buttons on a surface, and one is better; everything else steps down.

Sizes

large
medium
small
small Β· icon-sm
square Β· sm
icon-only
icon + label
disabled

Shapes

circle Β· xsmall
square Β· lg
square Β· md
small Β· icon-only
circle Β· lg
circle Β· md
circle Β· sm
primary
tertiary
ghost
primary-danger
dark
disabled-60

.button-circle squares the box itself, so .icon-only is not needed beside it. A circle can only ever be icon-only β€” a label inside one is an ellipse. Both shapes still need an aria-label: the icon is the only thing naming the action, and it names nothing to a screen reader.

Colours

primary
primary-brand
secondary
secondary-brand
tertiary
ghost
dark
primary-danger
secondary-danger
tertiary-danger
ghost-danger

Expressive

gradient-primary
gold-shimmer
pro-dark

Badges

Short, scannable metadata sitting next to the thing it describes β€” a status, a plan tier, an environment, a role. Not only plans. .pai-badge + a tone, optionally a size and .pai-badge-solid. A badge is static: if pressing it would do something, it is a button or a link.

Plan tiers

PROpro
GOLDgold
BASEbasic
FREEfree
NEWneutral

Status tones

Activesuccess
Expiringwarning
Faileddanger
Draftinfo
Archivedneutral
Paidicon + text

Solid

Livesuccess Β· 5.69:1
Expiringwarning Β· near-black Β· 9.39:1
Faileddanger Β· 4.83:1
Draftinfo Β· 6.06:1
Propro Β· 5.96:1
Betaneutral Β· 17.8:1

Solid reads as a claim where the tint reads as background, so it is for the one status on a row that has to be seen before the row is. One per row. Two, and the row is a set of coloured pills with no order β€” which is what a badge exists to avoid. Warning takes near-black text rather than white: white on #F0B100 measures 1.91:1.

Sizes

DRAFTdefault Β· 10px Β· caps
Draftmedium Β· 12px
Draftlarge Β· 14px

The two larger sizes drop the uppercase. At 10px you recognise the badge without reading it; at 14px caps is shouting, and a status is a word you read. Title Case in the markup, one word where possible β€” Active, not Currently Active; the row it sits in supplies the context.

Chips

.chip-style + size + tone. Selected is a state, not a variant.

Tones

default
selected
tone-success
success selected
tone-danger
danger selected

Sizes

chip-medium
chip-small

Leading icon

default
selected
tone-success
tone-danger
chip-small

Removable

Pricing Onboarding
chip-remove Β· click one
chip-input Β· type and press Enter

Tabs

.tabs-strip holding .tab-item, with one bar that slides under the selected one. Two sizes, and two switches β€” track puts a hairline under the whole strip, stretch splits the row equally instead of hugging labels.

Sizes

small Β· 32px
medium Β· 40px

With a track

small + track
medium + track

Stretched

stretch
stretch + track

Switcher

.pai-switcher holding .pai-switcher-option. One of N values, mutually exclusive. Not tabs β€” those switch a region, this switches a value. Not chips β€” those are filters you applied. Radios underneath, so arrow keys work and a form can read it.

Sizes

medium Β· 36px
small Β· 28px

Icon only

.pai-switcher-icons .pai-switcher-medium
.pai-switcher-icons .pai-switcher-small

Every segment still needs an aria-label. Without the word, the icon is the only thing naming the option, and it names nothing to a screen reader.

Carrying a badge

A discount is a status, so it takes a badge rather than a colour applied to a word.

Stretched

pai-switcher-stretch

Disabled

input:disabled

List items

.listitem. A row in a menu or a panel β€” it selects, opens or acts. Medium and small.

Medium

Small

Stacked

Dividers

.pai-divider. Horizontal, vertical, or carrying a label. Inside a card or a menu it runs end to end; a divider that stops short reads as a mistake rather than a separation.

Horizontal

Above


Below

pai-divider

Running to the edge of a padded card

The plain divider is width:100%, which resolves against the content box β€” so inside a padded card it stops short by exactly the padding. .pai-divider-bleed reaches past it. The card says how far by setting --divider-bleed to its own padding.

Above


Below

Above


Below

pai-divider-bleed · the second card is the plain divider, stopping short

Vertical

Draft Edited 2h ago 15 slides
pai-divider-vertical Β· align-self stretch

With a label

OR
pai-divider-labelled

Inputs

.pai-input, .pai-textarea, .pai-select. The select opens the system's own menu rather than the browser's, because an OS picker cannot be styled and looks different on every platform.

default
is-error
disabled
pai-textarea

Select

.pai-select
.is-empty
disabled
.pai-select-large
.is-error

The menu is .dropdown-menu-style at the field's width. Opening it belongs to the screen: toggle .is-closed and .is-open.

Checkbox Β· Radio Β· Toggle

.pai-checkbox, .pai-radio, .pai-toggle, each wrapped in .pai-field to pair with a label. They differ in when the change takes effect: a toggle applies immediately, a checkbox and a radio on submit.

Checkbox



Radio


Toggle

Avatars

Always round. .pai-avatar holds a picture or an icon; .pai-avatar-stack laps several together. Sized to the control heights β€” 28 Β· 36 Β· 44 β€” so one in a row lines up with the button or field beside it rather than sitting on a scale of its own.

What it holds

an <img>
MSinitials
a Phosphor icon

In that order of preference: a picture if there is one, initials if there is a name, the icon if there is neither β€” a seat nobody has taken, an invite not yet accepted. The ring is drawn over the picture rather than under it, so a pale photograph still has an edge against a white row.

Identity colours

DS.pai-avatar-indigo
MU.pai-avatar-violet
SG.pai-avatar-purple
TY.pai-avatar-fuchsia
AJ.pai-avatar-rose
EP.pai-avatar-orange
JL.pai-avatar-amber
NK.pai-avatar-lime
RM.pai-avatar-emerald
KV.pai-avatar-teal
PB.pai-avatar-cyan
HS.pai-avatar-blue

These carry no meaning. They tell one person from another and nothing else β€” an indigo avatar is not an indigo kind of person. The semantic tones the rest of the system uses are deliberately absent: an avatar is never in a state, there is no dangerous person, and a red circle beside a name reads as an accusation.

Pick deterministically from something stable β€” hash the user id, not the display name, or the circle changes colour when somebody corrects their surname. Same person, same colour, every screen. For an icon or initials only; a picture brings its own colour. Every ink clears 4.5:1 on its own tint, because initials are text.

Sizes

.pai-avatar-small
.pai-avatar
.pai-avatar-large

Stacked

+3 .pai-avatar-stack
+8 .pai-avatar-small

The counter sits on top of the last face, always β€” it is the end of the stack, and a number half-hidden behind a picture is a number nobody can read. It is .pai-avatar-more and not an avatar: nobody is called +3, and giving it a person's shape claims there is a person to press.

Cards

The claim comes from the affordance, not the box. .pai-surface-card on its own is a bounded region — it asserts nothing, and grouping content is what it is for. Add .is-interactive and it becomes a card: this is one thing, and you can act on it. That second thing is what "don't use a card for something that isn't clickable" is about.

The four states

Grouping only
A surface. No cursor, no hover, no claim.
Raised
On another white surface. 02 at rest.
.pai-surface-card · .is-interactive · .is-selected · .pai-surface-raised

With a divider that reaches the edge

A padded card sets --divider-bleed to its own padding, and the divider runs corner to corner. See Dividers.

Overnight
Back in 24 hours

1 revision · NDA · SOC 2
.pai-surface-card + .pai-divider-bleed

Two rules that still hold

  • Put .is-interactive on a real button, link or label. It supplies cursor, hover and a focus ring — it cannot make a div focusable, and a div you can click is unreachable by keyboard.
  • Do not nest a card in a card. The inner one claims to be a separate thing you can act on, and it is not. Nesting a surface in a surface is a different question — white on white needs the raised step.

Surface

Every plane a screen can put something on. A surface is not a colour — it is a background, an elevation and a stacking layer travelling together, and they have to travel together or the illusion breaks: a white box with no shadow at modal height reads as a hole in the page, and a shadow on the page background reads as a smudge.

The eight

SurfaceWhat it isBackgroundElevationLayerAlready in use as
pagethe floor everything sits on--bg-secondarynone—every screen body
sunkena well cut into the floor--bg-quaternarynone—.pai-progress track
cardcontent resting on the floor--bg-elevated01—a panel on a page
raisedone step up, still in flow--bg-elevated02raised.pai-modal-panel
overlayattached to a trigger--bg-elevated03overlay.dropdown-menu-style
scrimthe page dimmed--bg-scrimnonescrim.pai-scrim
modalwhat the scrim is for--bg-elevated04modal.pai-modal
noticespeaks over everything--bg-blackoutdark-04toast.pai-toast

Each one already existed somewhere in the system. This names what the components were doing separately rather than adding a ninth way of doing it.

Selected

A near-black ring with a hairline of the surface colour under it, so the ring never touches the card's own edge. It replaces the resting shadow — two edges on one card reads as a border that changed its mind. Add .is-selected to .pai-surface-card or .pai-surface-raised. Known: this is identical to :focus-visible today — see coverage-gaps.md.

.pai-surface-card.is-selected

Stacked

card, on page

raised, on card

sunken, cut into it

page → card → raised → sunken

Card and raised are both white with a shadow, and the difference is what they sit on. A card sits on the page and needs only enough lift to separate from grey. Raised sits on another white surface — a panel inside a modal — and needs more, because white on white has nothing but the shadow to say there are two planes.

Why eight, not the obvious seven

A side panel is not its own surface. It is the modal surface anchored to an edge — same background, same elevation, same layer, different geometry. Naming it would mean two things to keep in step for no difference a reader can see.

Two are easy to leave out and both earn a place. Sunken is the only surface that goes the other way; it is not a darker card, because nothing is meant to look like it is sitting on it — it is meant to look cut in. And scrim is a surface even though nothing rests on it: it is what makes the thing above it legible as above.

The layer scale

--z-base 0  ·  --z-raised 10  ·  --z-overlay 100  ·  --z-scrim 1000  ·  --z-modal 1010  ·  --z-toast 2000  ·  --z-tooltip 3000

The layer is the part this system was missing, and the corpus shows why: it carries z-index values of 0, 1, 2, 20, 30, 60, 99, 500, 999, 9999 and 2147483000 — which is what happens when every screen picks its own.

The gaps are deliberate, so something new can slot between two layers without renumbering everything above it. Modal sits above scrim by number rather than by DOM order, so a modal rendered before its own scrim still lands on top. Tooltip is highest because a tooltip can be triggered from inside a toast, and a toast can appear over a modal.

Grid

Equal columns with a gutter. .pai-grid takes --cols (or a count class) and --gutter, defaulting to four columns at 16px — the same 16px a modal pads with, so a grid inside one lines up with its edges.

Counts

1
2
.pai-grid-2
1
2
3
.pai-grid-3
1
2
3
4
.pai-grid-4

One to four, because that is what the screens draw. Across all three canonical screens and every design, the counts in use are repeat(3,1fr) eight times, 1fr 1fr five, repeat(4,1fr) four. There is no twelve-column grid anywhere in the corpus, so none is published — twelve would mean every screen dividing by three to get back to the four it wanted.

Spanning

span 2
3
4
span full
.pai-grid-span-2 Β· .pai-grid-span-full

As many as fit

1
2
3
4
5
6
7
.pai-grid-fill Β· --min: 150px

For a set whose length is not known when the screen is drawn — a deck of thumbnails, a list of brand kits — where a fixed count either leaves a hole at the end or squeezes to fit.

Gutter

1
2
3
4
--gutter: var(--space-2xs)
1
2
3
4
--gutter: var(--space-xl)

What it collapses at, and why those numbers

900px and 600px are the only two widths the corpus has ever branched on. They are not invented device sizes: 900 is where the analytics modal drops its two columns to one, and 600 is where the create flow does. A grid that collapses where the screens around it already do disappears; one with breakpoints of its own fights them.

Three and four halve to two at 900 before going to one at 600, so a four-up does not jump straight to a single column and double the page height. A grid that must not collapse sets --cols inline, which beats every rule above it — no opt-out class is published because nothing needs one yet.

Every column is minmax(0, 1fr) rather than 1fr. That is a fix for a bug the corpus has: a plain 1fr will not shrink below its content, so one long unbroken string — a file name, a URL — pushes the whole grid wider than its container instead of ellipsing.

Ticker

A row that scrolls forever, for the logos a screen shows to say who already uses this. .pai-ticker masks both edges and .pai-ticker-track carries the run. Hover anywhere to stop it; hover one logo to bring it up to full.

Default

AdobeAmazonBCGEYGoogleMcKinseyMetaMicrosoftNotion
.pai-ticker

Small, and faster

AdobeAmazonBCGEYGoogleMcKinseyMetaMicrosoftNotion
.pai-ticker-small Β· --ticker-duration: 22s

How the loop is seamless

The track holds its contents twice and travels exactly −50%. At the end of the run the second copy sits precisely where the first began, so the jump back to zero lands on an identical frame and cannot be seen. Duplicate the items or the loop visibly snaps — it is the one part of this that is not optional. Mark the copies data-ticker-clone aria-hidden="true": they are scaffolding, and a screen reader should hear the list once.

The edges are a mask-image rather than a gradient laid over the top, so the strip works on whatever surface it is dropped onto — a white overlay would only disappear against white. The logos sit grey and half-lit because they are somebody else's, used as evidence, and nine at full strength out-shout the screen they are evidence for.

Under prefers-reduced-motion it stops and becomes a scrollable row, and the duplicate copies are hidden — with no loop they are just a list printed twice. An endless sideways crawl is the exact thing that setting exists for.

Chart

A column chart, a donut, and the key that reads either. Every part is placed from --at, a fraction of the axis β€” 0 at the baseline, 1 at the top β€” so a tick, its gridline and the bar it measures stay in register at any height. A bar takes --value, the same 0–1 API as .pai-progress.

Column chart

075150225300
123456789101112
.pai-chart

One colour for every bar. Bars are already separated by position and share one scale, so a colour per bar encodes nothing and invents a category. Colour one differently only to mark it out — the one being compared against, the one that failed.

Slide 7 was viewed by nobody. It keeps a hairline through .is-empty rather than rendering at zero height β€” a bar with no height reads as a gap in the data, and this one says measured, and it was nothing.

Donut and legend

.pai-donut
Direct link
55%
Email
30%
LinkedIn
15%
.pai-legend

pathLength="1" on each arc is what lets --value be a fraction: it tells the browser to treat the circumference as one unit, so the same markup works at any radius and nothing computes 2πr. --at is where an arc starts β€” the sum of the arcs before it.

Distinct colours, never shades of one. A ramp reads as an order — low to high, worst to best — and Direct link, Email and LinkedIn are not steps of anything. Two tints of one hue meeting on a curve also read as one arc with a shadow on it: hue survives a 14px stroke, lightness does not. The same goes for the key, whose swatch is 10px — a reader can name a hue at that size but cannot place a tint on a scale.

What it does not do

The chart draws what it is given and computes nothing. Scale, ticks and rounding are the caller's β€” whether zero belongs on the axis and whether to round to a nice number are judgements about data the component cannot see, so a component that guessed would be guessing.

Thumbnail

A slide, at the size it is being looked at. .pai-thumbnail is the picture alone; .pai-thumbnail-card is the picture with a name under it and contains the first rather than repeating it. Always 16:9, because a presentation slide is.

Picture alone

.pai-thumbnail · the third has no image

It takes its width from whatever holds it and works out its own height, so there are no size classes. The one with no picture shows the sunken grey rather than white — a slide that has not loaded should read as a well waiting for one, not as a blank slide.

With a name under it

.pai-thumbnail-card

On its side

.pai-thumbnail-card-row

The picture takes its own radius back here, because it is surrounded by card rather than clipped by it. In the grid the card owns the corners and the picture is cut to them, so it has none of its own — a second curve inside the card’s reads as two edges.

Why the ratio is not negotiable

A presentation slide is 16:9 and every deck in assets/decks/ renders at 16:9, so a thumbnail at any other ratio is either letterboxing a slide or cropping one. The dashboard’s handoff drew 366.5 × 202 — 1.81:1, four pixels short, enough to letterbox every cover on the shelf.

display:block is load-bearing. aspect-ratio does nothing to an inline box, so as a bare <span> a thumbnail is sized by its content and the ratio is silently ignored. That is how the dashboard’s first covers came out at 1.32:1.

The picture is object-fit: cover, so a source that is not quite 16:9 is cropped rather than squashed. A crop loses an edge; a squash makes everything on the slide wrong.

Prompt

The box somebody types into to start something. A grey card holding a brand strip, a white box with the input, and a toolbar along the bottom of that box. .pai-prompt takes --rounded-xl, which the rounding scale reserves for exactly this. The toolbar restyles nothing β€” it holds .button-small .button-tertiary.

Resting

Set up Brand Kit to use your logo, colours, and voice PRO
.pai-prompt

The submit stays disabled until there is something to start. The strip centres while nothing is set, because there is nothing on the right to balance against.

Focused, with a brand kit applied

.is-focused Β· .pai-prompt-brand.is-set

A kit applied takes the card's tint with it, through --prompt-tint, so the whole card reads as belonging to that brand. The kit here is assets/brand-kits/airbnb β€” one of four, chosen to be four different problems rather than four logos.

Progress

Determinate and linear. The gradient is in the indicator, never the track, and it is pinned to the track's width rather than the indicator's β€” so a given point on the bar is the same colour at 20% as at 90%, and filling reveals a ramp instead of squeezing one. Navy at the start, brand by 80%, brand the rest of the way. Corners are square.

Fills

--value: .1
--value: .35
--value: .8
--value: 1

Thin

.pai-progress-thin Β· --value: .45
.pai-progress-thin Β· --value: .92

At 2px the ramp still reads, because it is pinned to the track β€” a gradient squeezed into the fill itself would be a single flat colour at this height.

Indeterminate

.pai-progress-indeterminate

A bar on its own does not say what is progressing. Name the work and the units beside it β€” Generating 12 of 20 slides, not a bar and a number.

Spinner

.pai-spinner. An action is running and nobody knows for how long β€” a button that was pressed, a row being fetched. Sized in em off the icon scale and drawn in currentColor, so it drops into a button and takes the size and colour the icon beside it would have taken. If you know the proportion, use progress instead.

.pai-spinner-xs
.pai-spinner
.pai-spinner-md
.text-text-brand
in .button-primary
in .button-secondary
in .icon-only

A tail fading out behind the head. It reads as motion rather than as a rotating object, so it sits in a dense row without pulling the eye.

Timeline

.pai-timeline. A long-running job, written down as it goes. A log, not a progress bar β€” every row is something that has happened, and nothing is greyed out ahead of time. A progress bar claims to know how many steps there are and how far along it is; a generation run knows neither. The head carries what is live; .is-active shimmers the row being worked on.

Letting it simmer… 33s
  1. Six projects in the template's case-study slides. Keeping the two that are brand and packaging, and one adjacent retail piece for range.

.pai-timeline Β· .is-open Β· .is-active

The rail breaks around each dot rather than running behind it β€” a stub above, a growing segment below β€” so it stays joined when a row opens and gets taller. The dot stays grey even on the active row: the shimmering label already says which one is live, and a second signal on the bullet would make the list look like it were counting.

Shimmer

Three ways content can be busy, and picking the wrong one makes the screen lie about whether there is anything to read. .pai-skeleton is a placeholder β€” the content is not here, and this is the shape it will take. .pai-shimmer is a state β€” the content is here and is being worked on, and stays readable. .pai-rewriting is the content being replaced, and deliberately does not. The first two draw the same sweep, from one definition, so they can never drift apart.

Skeleton

.pai-skeleton

Shimmer

Q3 review deckSyncing from source…
.pai-shimmer

Reach for the skeleton when there is nothing to read, and the shimmer when there is. A screen that hides what it already has while refreshing it reads as a screen that lost it.

Rewriting

A studio capabilities deck: what you do, who you have done it for, and what it costs.

at rest

A studio capabilities deck: what you do, who you have done it for, and what it costs.

.pai-rewriting

Reading a deck's copy while it is being rewritten means reading something about to be wrong β€” so this one hides it, and that is the difference from the shimmer. What survives is identity: shape, colour, count, position, all where they were. It says this is still your deck while saying not these words. It carries no sweep and is not a loading indicator on its own β€” it belongs beside a timeline or a spinner that already says what is happening.

Tooltips

A bubble that points at something. .pai-tooltip + a placement, optionally the brand tone and a shape. Placement names where the bubble sits, not where the tail points: top-left is a bubble above its target, aligned left, tail pointing down.

Above the target

Aligned left.pai-tooltip-top-left
Centred.pai-tooltip-top-center
Aligned right.pai-tooltip-top-right

Below the target

Aligned left.pai-tooltip-bottom-left
Centred.pai-tooltip-bottom-center
Aligned right.pai-tooltip-bottom-right

Beside the target

Left of it, top.pai-tooltip-left-top
Left of it.pai-tooltip-left-center
Left of it, bottom.pai-tooltip-left-bottom
Right of it, top.pai-tooltip-right-top
Right of it.pai-tooltip-right-center
Right of it, bottom.pai-tooltip-right-bottom

Tones

Default.pai-tooltip
Brand.pai-tooltip-brand

Pill

Offer ends in .pai-tooltip-brand .pai-tooltip-pill
Ends in .pai-tooltip-pill
Offer ends in .pai-tooltip-large

A <time> inside any tooltip is set in tabular numerals. The first one ticks β€” watch the width. Proportional digits re-measure every second and the bubble twitches for as long as it is on screen.

With a header

Plain bubble, no placement β€” no tail is drawn HeadingTooltip with a header and a longer descriptive body.

Table

.pai-table. No zebra striping and no vertical rules β€” a row is separated by one hairline, and two devices saying the same thing is one too many. Headers are set in the overline so they read as labels for the column rather than as the first row of data. A numeric column is right-aligned all the way up, header included β€” a header sitting left of the numbers it names points at the wrong edge.

Default

InvoicePlanStatusAmount
Jul 2026Pro, annualPaid$240.00
Jun 2026Pro, annualPaid$240.00
May 2026Pro, monthlyFailed$24.00
Apr 2026BasicRefunded$8.00
.pai-table

Interactive rows

MemberRoleDecks
Anna JohnOwner18
Ethan ParkerEditor7
Justus LangViewer142
.pai-table-interactive

Toast

.pai-toast. Something happened and the screen is saying so after the fact. Not a banner β€” a banner is a condition that persists and belongs in the layout; a toast is an event and leaves on its own, so nothing important may live only inside one. Positioning belongs to the screen, like the dropdown.

Deck moved to Marketing .pai-toast
Exporting 20 slides to PowerPoint .pai-toast .pai-spinner
Your card was declined .pai-toast-danger

Two tones. The default says a thing happened; danger says it did not. There is no success tone β€” a save that worked does not need green.

Typography

The ramp exists as classes only. A type step is a size, a line height and a weight together, so it cannot be a single custom property β€” this is the one place a value is not available as a token.

Heading 4xltext-heading-4xl
Heading 3xltext-heading-3xl
Heading 2xltext-heading-2xl
Heading xltext-heading-xl
Heading lgtext-heading-lg
Heading basetext-heading-base
Heading smtext-heading-sm
Body xl regulartext-body-xl-regular
Body lg mediumtext-body-lg-medium
Body base regulartext-body-base-regular
Body base semiboldtext-body-base-semibold
Body sm regulartext-body-sm-regular
Body xs regulartext-body-xs-regular
Overline smalltext-overline-small
Mono overlines β€” JetBrains Mono, for eyebrows and labels
Overline largetext-overline-large Β· 14px
Overline mediumtext-overline-medium Β· 12px
Quotations β€” Hedvig Letters Serif

It used to take me a full day to put a board deck together. Now the first draft is waiting for me.

text-quote-lg Β· 28px Β· the export modal's face, upright

It used to take me a full day to put a board deck together. Now the first draft is waiting for me.

text-quote-base Β· 20px Β· one step down

Elevation and focus

Two families. Elevation is how far off the page a surface sits; focus is that a thing is selected, which is not a depth at all β€” and it has to survive whatever is underneath it, which is why one ring cannot serve a white card and a photograph.

One shadow colour at three strengths β€” drop-1, drop-2, drop-3 β€” and each step adds a layer to the step below it. Every step carries the same 1px inner white highlight, so a surface catches light at its top edge whatever depth it sits at.

Near-black, never brand β€” blue reads as a state the component owns, black reads as the thing you have got hold of. surface and image lay a gap of the page colour down first, so the ring never touches the thing’s own edge. A dark region re-declares --bg-elevated and --border-primary-inverted, and the whole family follows it.

Colour

Two layers. A ramp step holds a value; a semantic token holds a reference to one. Components use the semantic and never the ramp. Every swatch below is read from pai.css rather than copied.

Modals

Three axes, not one list. Size sets how much of the screen it takes, orientation the aspect its content wants, and type decides whether the body sits on its own card. Every combination that means something is below β€” each tile drawing its footprint against the viewport at true proportion. Click one to open it.

Scrim

The layer between a modal and the page under it. .pai-scrim covers the viewport; --bg-scrim is the colour and --bg-scrim-blur the blur it puts on what is behind it. Plain black, because a tinted scrim pushes its colour through blurred content.

Over content

Q3 board deck
The page underneath, so the scrim has something to sit on and you can judge how much it holds back.
Delete this deck?
This cannot be undone.
--bg-scrim rgba(0,0,0,0.5)  Β·  --bg-scrim-blur 8px

Rounding

4px unless it is tiny, floats above the canvas, or is the thing the page is about. 2xl is the ceiling; above it there is only full.

none Β· 0
sm Β· 2
base Β· 4
md Β· 6
lg Β· 8
xl Β· 12
2xl Β· 16

Spacing

One scale for padding, margin and gap. Everything except the 2px hairline is a multiple of 4, and the step widens as the values grow.