Skip to content
Get the kit

Styling and theming

The extension UI follows Material 3 (M3), Google’s design system, including its 2025 Expressive update. One set of tokens covers every surface: popup, side panel, dialogs, and the on-page UI. The colors come from a palette, one file the website template shares, so a rebrand needs no component edits.

A palette is every brand color: the gray scale, the brand scale, and the color roles built from the brand scale. The kit ships six in packages/ui/palettes/, each checked for contrast in both themes:

palettefilled buttongrays
ember (default)bright orange, dark labelwarm
blueblue, white labelcool
violetviolet, white labelcool
tealdeep teal, white labelgreen-tinted
pinkhot pink, white labelrosy
monoblack in light, white in darkneutral

The setup wizard asks for one (Color palette, or --palette teal). To switch later, rerun the wizard with --palette <name>. The extension and the website import the palette on their first lines, and the wizard points both at the new file:

apps/extension/assets/tailwind.css
@import "tailwindcss";
@import "../../../packages/ui/palettes/teal.css";

The pages that import no palette get its colors written in: the checkout return and uninstall pages (backend/firebase-hosting/public/page.css, the uninstall page’s own form colors, and the Supabase function’s return-pages.ts, which inlines page.css) and the store asset templates (apps/extension/store-assets/templates/store.css). Each of their colors names its token, and the wizard rewrites the value from the palette you pick:

--eyebrow: #dd2c00; /* palette: accent-800 */
--glow: rgba(255, 138, 0, 0.1); /* palette: accent-500 / 10% */
--accent: #ff8a00; /* palette dark: brand */

palette dark: reads the dark theme, for a role that changes with it, like mono’s button. A color you add to one of these pages follows the palette once it carries a mark. The logo is an image and keeps its colors: replace apps/extension/public/icon/ and the website’s favicon with your own.

Copy the closest palette to a new file in packages/ui/palettes/, change its values, and run the wizard with --palette acme (the file’s name): it offers your file next to the kit’s six. A palette has three parts:

packages/ui/palettes/acme.css
@theme {
--color-*: initial;
/* the grays: the kit's lightness steps, tinted toward your hue */
--color-neutral-50: oklch(0.985 0.002 262);
/* … 100–950 … */
/* your brand's scale */
--color-accent-50: #eff6ff;
/* … 100–950 … */
--color-accent-ink: color-mix(in oklab, var(--color-accent-700), var(--color-accent-950) 35%);
/* the brand roles, light theme */
--color-primary: var(--color-accent-ink);
--color-secondary: var(--color-accent-100);
--color-secondary-foreground: var(--color-accent-ink);
--color-brand: var(--color-accent-600);
--color-brand-foreground: #fff;
}
@layer base {
/* the brand roles, dark theme */
.dark {
--color-primary: var(--color-accent-400);
--color-secondary: var(--color-accent-950);
--color-secondary-foreground: var(--color-accent-200);
}
}
  • The brand scale has eleven steps, 50 to 950. Tailwind v4’s color reference has ready-made scales, and a scale generator builds one around a single hex.
  • brand is the filled button. Point it at the step where brand-foreground reads: white on a deep color, near-black on a bright one.
  • For the grays, keep the lightness and chroma of the kit’s steps and set the hue (the last oklch number) to your brand’s.
  • --color-*: initial comes first: it turns off Tailwind’s stock palette, so raw utilities like bg-blue-600 don’t compile. Lint bans them too.

pnpm test checks every palette in the folder in both themes: text on every surface, the label on the filled button, the selected row, and the pages’ eyebrows and links each need 4.5:1. A failing check names the palette, the theme, and the pair. It also checks that the marked colors match the palette the extension imports.

The status scales (success, warning, danger) are the same in every palette. They live in assets/tailwind.css next to the roles built from them.

Components never name a scale step. They use the color roles, so every button, link, tab, dialog, and focus ring follows the palette. Each role has a light and a dark value and switches with the theme: you write one class, never a dark: pair. The names are the ones shadcn/ui uses, and X-foreground is the text that sits on X. The palette holds the brand roles (primary, secondary, brand); assets/tailwind.css builds the rest from the palette’s grays and the status scales. The values below are ember’s.

rolelightdarkused for
backgroundneutral-50neutral-900the page, lists, tabs, dialogs
foregroundneutral-900neutral-100text
muted-foregroundneutral-600neutral-400secondary text, icons
cardneutral-100between 900 and 800cards that float over a page
mutedbetween 200 and 300neutral-700skeletons, avatars, neutral chips
borderneutral-300neutral-700dividers, card and button edges
inputneutral-500neutral-400text fields, outlined controls
primaryaccent-inkaccent-400links, active tabs, selection, progress, focus
secondary / secondary-foregroundaccent-100 / accent-inkaccent-950 / accent-200selected options, the brand tint
brand / brand-foregroundaccent-500 / near-blackthe samefilled buttons
destructive / destructive-foregrounddanger-700 / whitedanger-300 / danger-950errors
success / success-foregroundsuccess-50 / success-800success-950 / success-200a success tint and its text
warning / warning-foregroundwarning-50 / warning-950warning-950 / warning-200a warning tint and its text

primary reads as text on every surface, dialogs included (WCAG AA). Its light value is accent-ink, accent-700 darkened toward accent-950: in ember, the ramp’s own dark end turns red, and a red primary would read as an error. The brand at full strength is reserved for filled buttons (bg-brand text-brand-foreground), the same in both themes in every palette but mono. Stock Material 3 fills buttons with primary; the kit fills them with the brand.

A tinted status (a Badge variant, a BroadcastBanner level) is a role pair with its foreground at 20% as the edge: bg-success text-success-foreground border-success-foreground/20. A snackbar inverts the page: bg-foreground text-background. A broadcast’s info level is neutral, bg-muted with border-border, so it never reads like a warning.

Dark mode is class strategy, driven by the settings store: auto follows the OS; light and dark override it. The .dark class redefines the roles wherever it sits, and the page background is the background role in both themes. Check every new component in both themes.

Content-script shadow UIs get the theme class on their shadow wrapper, never from the host page (mountShadowUi handles this).

  • The Google trio, bundled locally in packages/ui/fonts/; no CDN fonts. Google Sans carries body and UI, Google Sans Flex carries headings (the display face), Google Sans Code carries uids, shortcuts, and stats.
  • @font-face lives in @extensionstart/ui/fonts.css, imported by HTML surfaces only. The content script never loads font files: shadow UI on web pages uses the system fallbacks from the font tokens, which keeps font requests off host pages and out of web_accessible_resources.
  • Weights are 400 and 500. Hierarchy comes from size and tone.
  • Numbers always get tabular-nums (prices, credit counts, timers) so digits don’t jiggle.

Text follows the M3 type scale:

M3 rolesize/lineclassused for
Title large20/28text-xl leading-7 font-mediumdialog titles, the sign-in title
Title medium16/24text-base leading-6 font-mediumcard titles, prices
Title small14/20text-sm font-mediumthe active tab, plan names
Body large16/24text-baselist rows, text fields
Body medium14/20text-smsupporting text, dialog text, snackbars
Body small12/16text-xsfloated labels, notes
Label large14/20text-sm font-mediumbuttons

The M3 shape scale, on the kit’s radius utilities:

M3 shaperadiusused for
extra-small, 4pxrounded-smtext fields, snackbars, menus
small, 8pxrounded-lgchips
medium, 12pxrounded-xlcards, option rows, square buttons
large, 16pxrounded-2xllarge containers
extra-large, 28pxrounded-3xldialogs
fullrounded-fullbuttons, pills, avatars

Fades and small translates only, 150–200 ms, hard cap 300 ms, with M3’s standard easing (ease-standard). Extension surfaces open and close constantly; motion that draws attention twice a minute is noise. The one sanctioned entrance: animate-enter (a fade and a 16px rise, 200 ms, emphasized decelerate, defined in assets/tailwind.css) on transient chrome, with motion-reduce:animate-none.

A 4 px grid (the Tailwind default) and M3’s component sizes: buttons 40 px (32 compact), list rows 56, 72, or 88 px with 16 px sides and 12 px gaps, text fields 56 px, dialogs with 24 px padding. The popup’s minimum width is min-w-90 (360 px); a dialog fits under the popup’s 600 px cap.

Primitives come from @extensionstart/ui, each one an M3 component:

  • Button: filled (default), outlined (outline), text (ghost), and danger; sizes sm (32 px), default (40 px), and icon (a 40 px icon button); round, or shape="square" for a button with its own edge.
  • Card: the outlined card.
  • TextField: the outlined text field with a floating label.
  • Modal with DialogBody: a header row with your logo, the title and the close button; its actions stack full width, the confirming button on top and the dismissing text button under it, so neither moves when a label changes.
  • ListItem: one-, two-, and three-line rows.
  • Badge for status chips, Skeleton, and Toaster for snackbars: one at a time (a new one replaces the one on screen), on a layer under dialogs, so a wall that follows a snackbar always takes the clicks.

The popup’s tab bar is plain markup in AccountHub: icon and label, the active tab underlined in primary. The theme choice in Settings is three buttons, the selected one filled.

Never hand-roll a button, list row, dialog, or badge in app code; extend via className, merged with cn(). Hover, focus, and pressed state layers and the 3 px focus ring are built into the primitives. A custom interactive element gets the same with two utilities: state-layer (it needs relative) and focus-ring (or focus-ring-inset when it sits edge to edge). The rules and numbers coding agents follow are in AGENTS.md (Conventions).

Two practical notes:

  • New Tailwind class sources outside the extension app need an @source line in assets/tailwind.css.
  • In shadow UIs, rem is converted to px at build (rem would resolve against the host page’s root font size); see UI on web pages.