[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. 🫖 Status page with uptime monitoring & API monitoring as code 🫖 openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
openstatus DESIGN.md
15 kB

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-foreground and lift only the word that matters to text-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, merge className with cn, always set data-slot.
  • Parent context is expressed in CSS, not props: in-data-[slot=timeline-card]:text-foreground lets an actor render muted in a row and bold in a card without a variant prop.
  • 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. Timeline has 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 #

  • cva for the visual axes of a part, VariantProps for the type. Status variants are default | success | warning | destructive | info, pinned with satisfies Record<StatusVariant, string> so a missing one fails typecheck.
  • className is 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, never space-x-* / space-y-*. size-* when width equals height. truncate plus min-w-0 on 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, cards px-4, inside a row gap-2.5, stacked compact rows pull up with a short rail. Pages cap at max-w-6xl. Padding is symmetric unless the asymmetry is explained in a comment.
  • Empty states use the dashed EmptyStateContainer with 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 in packages/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 .dark sibling 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 use StatusBadge 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.json cssVars too, 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-nums on 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-foreground for 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 from HoverCardTimestamp; 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-out for anything entering or leaving. ease-in-out for movement on screen. ease for color and hover. Never ease-in.
  • Transition named properties, never transition-all. Prefer transform and opacity; 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 stock ui/button stays untouched; add it through a wrapper class, not by editing ui/.
  • 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-style for enter animations when support allows, the data-mounted pattern otherwise. No useEffect just to animate.

Interaction and accessibility #

  • Native elements. A button acts, a link navigates. No div with role="button". Space and Enter both work on a button because it is one.
  • Every icon-only control has a label: aria-label plus a tooltip that shows the same text and the shortcut if there is one. Decorative glyphs (StatusDot, rail icons) are aria-hidden.
  • Focus is visible. Never remove the focus-visible ring; 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-only when the design hides it.
  • Forms. Every control has a connected label. Placeholder text is not a label. Validation sets aria-invalid on the control and data-invalid on the field. Errors read next to the field, not only in a toast.
  • Keyboard. ⌘↵ submits a composer, Escape leaves it, a single letter toggles a mode (E for 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. Skeleton for layouts, Spinner with disabled on a button. No isLoading prop 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 compiles hover: to (hover: hover); do not reintroduce ungated hover with raw CSS. Touch users get :active feedback instead.
  • touch-action: manipulation on tappable elements removes the double-tap delay. -webkit-tap-highlight-color: transparent globally, then every tappable has its own :active state.
  • Inputs are 16px on touch so iOS does not zoom. Never disable zoom.
  • 100dvh for app shells, drawers and bottom-pinned UI; 100svh for heroes. Fixed bars pad with env(safe-area-inset-*) and the viewport meta sets viewport-fit=cover.
  • Swap structure with useIsMobile only 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: ol for ordered timelines, time with dateTime for every timestamp, header/section/aside where the page has those parts, one h1, 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 width and height or fill; no layout shift on load.
  • color-scheme: light dark on the root; theme-color once per scheme.
  • No manual z-index on overlays; the primitives own the stack.
  • inert on 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, on desktop and mobile (light; dark on request) 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.