DESIGN.md #
How openstatus UI should look, feel and be built. AGENTS.md says where code
lives and what breaks; this file says what good looks like. Read both before
touching anything a user sees. Rules are short on purpose: the reason is the
rule, and the exception is in review.
Principles #
- Hierarchy over decoration. A page has one loud level and one quiet level. Authored content (a note, an update, a postmortem) is loud. State changes and system events are quiet. If everything is emphasized, nothing is.
- Quiet by default. Start from
text-muted-foregroundand lift only the word that matters totext-foreground. Borders, fills and color are earned. - Compose, don't invent. A new screen is existing primitives in a new arrangement. A new primitive is a reviewed decision, not a side effect of a feature.
- Unseen details compound. Nobody notices a 160ms press or a dashed-underline hover on its own. Together they are why the product feels right.
- Learnable by repetition. The same shape on every incident, report and maintenance teaches the workflow without a tour. Consistency across pages beats a better idea on one page.
Components #
Where things live #
| Layer | Path | Rule |
|---|---|---|
| shadcn primitives | packages/ui/src/components/ui/ |
Stock. Edited only as a documented last resort, see packages/ui/AGENTS.md. |
| Public blocks | packages/ui/src/components/blocks/ |
Published registry. Imports nothing from an app; apps import them freely. |
| Shared custom | packages/ui/src/components/custom/ |
Ours, used by more than one app. |
| App kits | apps/*/src/components/content/, common/ |
Compound kits a whole app composes from. |
| Feature rows | apps/*/src/components/<feature>/ |
What a feature renders with the kits. |
A repeated visual is a new part in a kit, never a copied class stack. A feature-specific row (an incident event, a report update) lives in the feature folder and imports the kit; it does not add exports to the kit.
Compound components #
One kit per concept, small export list, every part a plain element wrapper:
export function TimelineItem({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li data-slot="timeline-item" className={cn("relative grid …", className)} {...props}>
{children}
</li>
);
}
- Props are
React.ComponentProps<"tag">plus the few the part owns. Spread the rest, mergeclassNamewithcn, always setdata-slot. - Parent context is expressed in CSS, not props:
in-data-[slot=timeline-card]:text-foregroundlets an actor render muted in a row and bold in a card without avariantprop. - State is expressed through attributes the browser already sets:
aria-pressed:,data-[state=open]:,aria-invalid:,disabled:. Style the attribute, never a parallel boolean class. - Keep the kit at the number of exports it needs.
Timelinehas eleven. If a feature needs a twelfth, first ask whether it is a row in the feature file. - Children, not render props, for layout. Slot props (
renderMessage,asChild) only where a block must stay free of app dependencies.
Variants #
cvafor the visual axes of a part,VariantPropsfor the type. Status variants aredefault | success | warning | destructive | info, pinned withsatisfies Record<StatusVariant, string>so a missing one fails typecheck.classNameis for layout (margin, width, grid placement). It is not for restyling a part's colors or type; add a variant instead.- Prefer a boolean switch (
dot,plain) over a new variant when it only removes chrome. gap-*on flex and grid, neverspace-x-*/space-y-*.size-*when width equals height.truncateplusmin-w-0on the flex child.
Primitives #
When a shadcn primitive is not enough, in order: pass className, wrap it in
a kit part, add a variant to the wrapper. Editing ui/ is a last resort with a
comment saying why. Shared UI comes from @openstatus/ui; an app never forks a
primitive (docs/adr/0003).
Hierarchy and layout #
- Two row kinds, one rail. A list of events has a compact sentence row (icon, muted sentence, time) and a card (avatar, header, body, optional footer, actions top-right). The rule for which: an authored message is a card, everything else is a row. The rail runs between every item, including card to card and composer to first item.
- Sentences, not labels. A row reads "Thibault marked Resolved", not "Status changed · Thibault". The actor and the lifted value carry the only emphasis.
- Composer first, newest first. On anything time-ordered the input sits on top and the latest item is directly below it. During an incident you want to know what just happened.
- Content left, properties right. Detail pages are a main column (header, composer, timeline, postmortem) and an aside of label/value properties. The aside is reference; it never competes with the timeline. A linked entity is one row with its actions behind a menu, not a card with a primary button.
- Section title plus count.
Activity 10,Updates 3. The count is mono and muted. - Spacing. Lists
gap-4, cardspx-4, inside a rowgap-2.5, stacked compact rows pull up with a short rail. Pages cap atmax-w-6xl. Padding is symmetric unless the asymmetry is explained in a comment. - Empty states use the dashed
EmptyStateContainerwith a title and one sentence that says what to do next. Never a bare "No data". - Inline editing. Title and summary are borderless inputs that show their edge on hover and focus. Save on blur or explicit action; never on every keystroke.
- Hover affordance is a dashed, muted underline (
decoration-dashed decoration-muted-foreground/50 underline-offset-2). Not a color change, not a chevron, not a dot.
Color #
- Tokens only:
background,foreground,muted,muted-foreground,border,ring,success,warning,info,destructive. They are OKLCH inpackages/ui/src/globals.css. Never a raw palette class (text-emerald-600). - Dark mode comes from the tokens. No
dark:color overrides in components. Images in content pages get a.darksibling file. - Status color sits on the smallest element that can carry it: a
StatusDot, the word in a sentence, the icon in a disc. Chrome stays neutral. - Tinted chrome is one recipe:
border-{token}/20 bg-{token}/10 text-{token}. Inside a sentence useStatusBadge plain(dot and mono text, no border). Bordered badges belong in cards and tables. - Impact and status labels on the status page are colored text only.
- A new token goes into
registry.jsoncssVarstoo, or it is undefined for everyone who installs a block. - Contrast: 4.5:1 for text, 3:1 for icons, focus rings and borders that carry meaning. Muted text on a muted fill is the usual offender; check it in both themes.
Typography #
- Sans for prose, labels and buttons. Mono for values: ids, counts, dates, durations, statuses, anything a user might copy or compare.
tabular-numson any number that updates in place so columns do not jitter.- No font sizes beyond the scale. Hierarchy comes from weight and color
before size:
font-medium text-foregroundfor the lifted word, size only for the page title. - Relative time in the row (
2d ago), absolute time with timezone in a hover card. Both fromHoverCardTimestamp; never format a date inline. - Wrapped sentences are allowed. Icons top-align (
items-start) so a two-line row still reads. - Sentence case everywhere. Brand is
openstatus, lowercase, except at the start of a sentence. Click paths use>:Settings > Integrations.
Radius #
The scale is proportional to the themed --radius (xs 0.2, sm 0.6, md
0.8, lg 1.0, xl 1.4). Prefer rounded-lg. Never hard-code a pixel radius.
rounded-full only wraps a glyph. The status page and the marketing site set
--radius to 0, so a radius in a demo is dead code. Details in
packages/ui/AGENTS.md.
Motion #
CSS transitions only. There is no animation library in this repo and none is planned.
- Ask first whether it should move. Something seen a hundred times a day (command menu, keyboard shortcuts, list navigation) does not animate. Hover and press get feedback, not motion. Modals, drawers and toasts get a standard transition. Only rare moments may delight.
- Durations. Press feedback 100–160ms. Tooltips 125–200ms. Dropdowns and selects 150–250ms. Modals and drawers 200–300ms. Nothing in the UI over 300ms.
- Easing.
ease-outfor anything entering or leaving.ease-in-outfor movement on screen.easefor color and hover. Neverease-in. - Transition named properties, never
transition-all. Prefertransformandopacity; they do not trigger layout. - Press feedback on pressables we own (kit parts, blocks, custom):
active:scale-[0.97]with the press duration above, on press rather than release. The stockui/buttonstays untouched; add it through a wrapper class, not by editingui/. - Never enter from
scale(0). Start at 0.95 or higher with opacity. - Popovers scale from their trigger
(
origin-(--radix-popover-content-transform-origin)). Modals stay centered. - Second tooltip opens instantly. The delay guards the first hover only.
motion-reduce:on every transition that moves or scales. Fades may stay.@starting-stylefor enter animations when support allows, thedata-mountedpattern otherwise. NouseEffectjust to animate.
Interaction and accessibility #
- Native elements. A
buttonacts, a link navigates. Nodivwithrole="button". Space and Enter both work on a button because it is one. - Every icon-only control has a label:
aria-labelplus a tooltip that shows the same text and the shortcut if there is one. Decorative glyphs (StatusDot, rail icons) arearia-hidden. - Focus is visible. Never remove the
focus-visiblering; override it with the token ring if a layout needs it. Keep focus inside overlays and return it to the trigger on close. The shadcn primitives already do this; a custom popover must too. - Overlays have a title. Dialog, Sheet, Drawer always get a title;
sr-onlywhen the design hides it. - Forms. Every control has a connected label. Placeholder text is not a
label. Validation sets
aria-invalidon the control anddata-invalidon the field. Errors read next to the field, not only in a toast. - Keyboard.
⌘↵submits a composer,Escapeleaves it, a single letter toggles a mode (Efor preview). Shortcuts ignore key repeat, ignore typing inside inputs, textareas and open overlays, and show in the tooltip. - Targets. 24×24px minimum on desktop, 44×44px where touch is likely. Pad the hit area, not the icon.
- Loading.
Skeletonfor layouts,Spinnerwithdisabledon a button. NoisLoadingprop on a primitive; compose it. - Destructive actions confirm with a dialog that names the thing being removed. The last item in a required list cannot be deleted; hide the action rather than erroring.
- Toasts carry an error the user can act on. A failed background call is never silent.
Responsive and mobile #
- Mobile first. Breakpoints follow the content, not devices.
sm:for two columns,md:for the full layout. A four-column grid steps through two. - No horizontal page scroll at 320px. Tables overflow in their own scroller
(
overscroll-behavior: contain); pages never do. hover:styles apply only where hover exists. Tailwind v4 compileshover:to(hover: hover); do not reintroduce ungated hover with raw CSS. Touch users get:activefeedback instead.touch-action: manipulationon tappable elements removes the double-tap delay.-webkit-tap-highlight-color: transparentglobally, then every tappable has its own:activestate.- Inputs are 16px on touch so iOS does not zoom. Never disable zoom.
100dvhfor app shells, drawers and bottom-pinned UI;100svhfor heroes. Fixed bars pad withenv(safe-area-inset-*)and the viewport meta setsviewport-fit=cover.- Swap structure with
useIsMobileonly when CSS cannot: Dialog becomes Drawer, a row of actions becomes a menu. Everything else is a breakpoint. - Gate on capability (
(pointer: coarse),(hover: hover)), never on the user agent. - Test on a phone, not only in the emulator. Sticky hover, the keyboard and safe areas do not reproduce.
HTML #
- Semantic first:
olfor ordered timelines,timewithdateTimefor every timestamp,header/section/asidewhere the page has those parts, oneh1, headings in order. - Interactive inside interactive is a bug: no button in a link, no link in a button. A card that is clickable has one link, overlaid, and the rest is inert to the click.
- Images declare
widthandheightorfill; no layout shift on load. color-scheme: light darkon the root;theme-coloronce per scheme.- No manual
z-indexon overlays; the primitives own the stack. inerton background content while an overlay is open comes from the primitive. Do not reimplement focus trapping.
Worst cases #
Before calling a view done, render it with:
- a 255-character title, an email with no break opportunity, a one-letter name, a name with emoji
- no avatar, no author (legacy rows), no description
- 0, 1 and 1,000 items; a count of 1 that must not say "1 updates"
- 320px wide, the widest layout, 200% zoom
- both themes
Flex failures look like squished avatars (shrink-0), text that overflows
instead of wrapping (min-w-0), actions pushed off-screen, badges on two
lines, and a last row clipped mid-glyph.
Verify by looking #
An agent cannot judge rendering from a diff. For any visible change,
scripts/screenshot.mjs (the screenshot skill) shoots the element, not the
viewport, in both themes on the local dev server of the dashboard, the
marketing site or the status page, logging in with a seeded session where
needed. Selectors differ per app: [data-slot=timeline] and the other
dashboard slots, main or a section on the marketing site, the block slots
from packages/ui/src/components/blocks/ on the status page. Compare against the previous state. Feedback
goes back the same way: a screenshot with the problem boxed and one sentence.
Marketing and docs pages render every component on /kitchen-sink so drift
is visible there first.