Skip to content

UI design system

Aurral’s interface puts the music first. Artwork supplies the color. Everything else is black, white, and gray, set in one typeface on a short type scale. This page is the reference for how Aurral looks and the tokens and controls that keep it consistent.

  • Artwork is the color. The interface is grayscale. Brand green is a rare signal, never a theme.
  • Weight and size carry hierarchy. Do not add a divider, box, or label to separate things that spacing and type can separate.
  • Remove before adding. Every border, badge, icon, and line of copy costs attention.
  • One way to do each thing. A page title, a section heading, a play button, and an empty state look the same on every page.
  • Every action answers itself. After a click, the target changes, a destination opens, progress starts, or a toast names the result.

Aurral uses Inter Variable with its optical size axis, loaded from @fontsource-variable/inter. Use var(--font-sans) for text and var(--font-mono) for paths, versions, and other machine values.

Every font-size uses a --aurral-font-* token:

Token Size Use
--aurral-font-display 40 to 88px, fluid Artist name over the hero
--aurral-font-title 28 to 36px, fluid Page and collection titles
--aurral-font-xl 20px Section headings
--aurral-font-lg 18px Settings groups, empty state titles
--aurral-font-md 16px Mobile tabs, prominent rows
--aurral-font-base 14px Body, buttons, track titles
--aurral-font-sm 13px Secondary text, table cells, links
--aurral-font-xs 12px Metadata under a title
--aurral-font-2xs 11px Table headings, counts

Use weights 400, 500, 600, and 700 only. Titles and section headings use 700. Buttons, tabs, and row titles that need emphasis use 600.

Titles tighten with --aurral-tracking-title, and the display size uses --aurral-tracking-display. All other text uses normal tracking. Write labels in sentence case. Do not add uppercase labels, wide tracking, or a small eyebrow above a title.

Wrapped’s story cards are posters, not interface. They set their own type in frontend/src/pages/wrapped.css, which the checker exempts from the type and radius rules.

Every page starts with one .page-title in the top left, at the page gutter. Detail pages use CollectionHeader. Put the page’s actions beside or under the title, never in a bar with a border.

Sections use one heading style: --aurral-font-xl, weight 700, title tracking. A section links to its full list with a muted “Show all” text link on the right, using .section-show-all. Do not draw a line under a title or between sections.

Track rows have no dividers. On devices with a mouse, the track number turns into a play button on hover, and row and card menus appear on hover or focus. Touch screens show them all the time. Keep anything that reports progress, such as a download spinner, visible. Do not repeat a status that every item in a section shares, such as the library check in Recently added.

The dark theme uses a true black page. The sidebar and top bar sit one step lighter, and everything else steps up in small, neutral increments:

Token Dark value Use
--aurral-surface #000 Page
--aurral-chrome #050505 Sidebar, top bar, player, queue tray
--aurral-surface-raised #0e0e0e Cards that need a surface
--aurral-surface-popover #0e0e0e Tooltips, menus, toasts, modals, sheets
--aurral-surface-field #151515 Inputs and selects
--aurral-surface-hover #1b1b1b Hover on rows and controls
--aurral-surface-selected #1e1e1e The active row

On desktop the page is one rounded panel inside a chrome gutter. The top bar, sidebar, player, and queue tray all sit in that gutter on one flat chrome color with no panels of their own, and a side with nothing in it keeps an 8px gutter.

Everything that floats above the page shares one surface: --aurral-surface-popover, a --aurral-border-overlay border, and --aurral-shadow-popover. Floating surfaces are solid, and nothing blurs the page behind them. Modals sit on --aurral-scrim.

Pages with an artwork wash swap --aurral-surface-raised, --aurral-surface-hover, and --aurral-surface-selected for see-through versions, so controls pick up the color beneath them. The shared floating rules reset them to solid values. A control that sits on artwork uses the solid --aurral-overlay-raised and --aurral-overlay-hover instead.

Two shared rules near the end of frontend/src/index.css apply the surface. One covers menus, popovers, suggestions, and toasts, and the other covers modals. Add a new floating element to the matching selector list instead of setting its border, radius, background, or shadow in its own rule. The shared rules come later in the file, so those values would never show. Tooltips set the surface themselves and use --aurral-radius-sm. Build a new menu on an existing menu class such as .artist-options-menu when you can. It already has the surface and opens as a bottom sheet on phones.

Fields sit one step above whatever holds them, so an input reads the same on the page, in a settings card, or in a modal. Use Select from components/Select.tsx for every dropdown instead of a native <select>. It opens on the menu surface, and as a bottom sheet on phones.

Text uses --aurral-text (white), --aurral-text-muted for secondary text, and --aurral-text-subtle for metadata and placeholders. --aurral-accent is white in the dark theme. Immersive mode tints it from artwork, so components use the token and never pick a color.

Status colors are the exception to grayscale: --aurral-success for in the Library, --aurral-warning for incomplete or needs attention, --aurral-danger for failed, and --aurral-scheduled for an album that downloads on its release date.

Token Value Use
--aurral-radius-xs 4px Small chips, key hints, progress bars
--aurral-radius-sm 6px Buttons, rows, menu items, tooltips
--aurral-radius 8px Artwork, cards, inputs, selects, search
--aurral-radius-popover 10px Menus, popovers, suggestions, toasts
--aurral-radius-lg 12px Large cards and panels
--aurral-radius-modal 16px Modals and sheets
--aurral-radius-round pill Play buttons, round toggles, tag pills

Artist images are circles. Album and playlist artwork uses --aurral-radius.

  • Play is round and filled with --aurral-accent. It is the only round button, and each view has at most one.
  • Primary (.btn-primary) inverts the page: white fill and black text in the dark theme. Use it for the one action that moves the user forward.
  • Secondary (.btn-secondary) is for alternatives and retries.
  • Icon buttons have no border or fill until hover. Give each one a label through TooltipButton.

Icons come from frontend/src/components/icons.tsx, which wraps Untitled UI icons at a 1.5 stroke. Play and Pause are filled. Size icons with the --aurral-icon-* tokens: xs 14px inline with small text, sm 16px in buttons and rows, the base 20px in navigation, lg 24px for artwork fallbacks, and xl 32px in empty and error states.

Use .search-empty-panel for empty and error states: an icon in .search-empty-panel__icon, a title, one short message, and at most one action. Errors use AlertCircle, role="alert", a title that starts with “Couldn’t load”, and a “Try again” button when retrying can help.

Loading states use skeletons shaped like the ready state. Use DotLoader only where content cannot be predicted.

Settings is one modal over the current page. Its location is the settings search param, as ?settings=<section> or ?settings=<section>/<item>. Link to it with SettingsLink from navigation/settingsRoute.tsx, so it opens over the page the user is on.

The modal fills the window, up to 80rem wide. Below 768px it becomes a full-screen list that opens one page at a time.

Build every section from the primitives in pages/Settings/SettingsLayout.tsx:

  • SettingsPage owns the header bar: a breadcrumb (Settings / Section / Item, where the section goes back), the saving state, and close. Its content sits in a centered column.
  • SettingsGroup is a heading at --aurral-font-lg above one card on --aurral-surface-raised, with a hairline between rows. A group’s note is the first line inside the card. Put nothing between the heading and the card. Settings groups are the only cards that hold rows.
  • SettingsRow puts the label and one line of help on the left and the control on the right. Use stack for wide controls such as text areas.
  • SettingsServiceRow shows a service name, a short status (On, Off, Not set up, or Needs setup), and a chevron. It opens the service’s detail in the same pane. Do not add card grids or a modal per service.
  • Use a table only for a few short columns. When each item has a description or several values, use two-line rows like Status > Tasks: name and description on the left, schedule and result on the right.

Larger editors that are not settings use the same frame: SettingsShell for the modal and its list, and SettingsPane for the bar and content column. The flow editor lists flows, and playlist import lists sources. An action that moves forward, such as Import, sits in the bar.

Account sections show for everyone. Server sections show only to people who can manage the server, and their data never loads for anyone else. Changes save automatically. Toggles, selects, and actions save at once. A text field saves when focus leaves it, so a half-typed value never saves. Queue a save through afterTextEntry in utils/afterTextEntry.ts to get this rule. A detail that edits a draft, such as a webhook, saves with its own button.

Use the --aurral-duration-* and --aurral-ease-* tokens. Nothing loops forever. The exceptions are the now-playing equalizer, DotLoader, and the Wrapped record, which spins only while its song plays. All three animate only transform or opacity and stop when the user prefers reduced motion. The full screen player’s visualizer is a WebGL shader capped at 30 fps. It draws only while music plays and the player is open, and holds a still frame when the user prefers reduced motion. Do not add pulses, shimmers, or spinners that keep running.

Run pnpm run lint:ui from the repository root to check stylesheets and JSX. Run pnpm run check to include the UI checks with formatting and the other lint rules.

CI runs pnpm run check, so it reports UI violations with the other checks. The stylesheet checker enforces the type scale, weights, tracking, font families, radius tokens, and the looping animation exceptions listed above.

Add global design tokens to frontend/src/index.css. The CSS checker reads token names from that file, so do not copy the token list into another config.

Use an existing --aurral-* token for theme colors, surfaces, borders, rings, shadows, spacing, and radii. CSS and JSX inline styles cannot use literal colors for Aurral theme styling. Use var(--aurral-text) instead of #171717, for example.

The checker fails on literal colors and on undefined --aurral-* references in CSS and JSX. Use the spacing, radius, and shadow tokens when their roles fit. The checker cannot tell whether a local size should use a token, so check those values against the component’s layout.

Name a new token for its role. Use light-dark() when the role needs different values in light and dark themes. Keep local variables when they derive from Aurral tokens or hold runtime values such as dimensions and progress.

Artwork can use colors extracted from the artwork. Keep those values tied to artwork content. Do not use artwork colors for Aurral controls or page chrome. The checker allows the fallback used by the discover playlist artwork treatment.

The stylesheet owns Aurral’s own light and dark colors. Other themes are stored as a few input colors, and frontend/src/utils/theme.ts generates the color roles from them with contrast targets and writes them over the stylesheet values. A token outside those roles keeps Aurral’s value in every theme, so derive a new color token from a role token when it should follow the theme. The Immersive mode setting, on by default, is the one exception to the artwork rule above. It feeds the cover color through the same contrast checks, so components should not apply artwork colors to controls themselves.

Immersive mode also controls artwork washes at the top of Discover, Recommended, Trending, and detail pages and in Now playing, plus decorative artwork backdrops in search. Use useArtworkWash for extracted background colors so they follow this preference. To tint a card’s hover from its artwork, spread artworkHoverTint on the card and read --card-tint-surface and --card-tint-text in its CSS with a neutral fallback. The theme document keeps the stored matchArtwork field so existing account and browser preferences carry over.

Use .btn with the existing variant classes for standard actions:

<button type="button" className="btn btn-primary">
Save
</button>

Use TooltipButton for icon-only actions. Its tooltip waits one second on hover, so moving the pointer across a toolbar stays quiet. Keyboard focus shows it right away. Give it a label, and include .btn with the button variants:

<TooltipButton label="Refresh" className="btn btn-ghost btn-icon" onClick={refresh}>
<RefreshCw aria-hidden="true" />
</TooltipButton>

Use Tooltip for a tooltip on another element. A Tooltip opens on hover and on keyboard focus, but not on a mouse click that moves focus. The linter rejects native title tooltips on HTML elements. Keep iframe titles, because they name the frame for assistive technology.

<Tooltip content={fullTrackName}>
<span className="track-title">{fullTrackName}</span>
</Tooltip>

Use AddActionButton for actions that add an artist or album to the Library. It includes the standard button classes, the loading state, and the active manager’s label. Pass items to open a menu of add choices instead.

Keep a component-specific control class when the control has a dedicated layout or interaction. The button rule checks bare buttons and classes in the .btn-* family. The theme-token checks still apply to custom CSS and inline styles.

Menu items and switches have different roles. Use role="menuitem" or role="switch" only when the control has that behavior.

The linter checks token use and a few shared controls. It cannot judge spacing, visual hierarchy, interaction states, keyboard flow, responsive behavior, or whether a new component fits Aurral’s existing patterns. Review those details against the current interface and style references when you change UI.