Audience: contributors changing UI styles, components, or page layouts.
This project uses two theming layers:
- CSS custom properties in
app/globals.cssfor global browser-level values. - Tailwind theme extensions in
tailwind.config.jsfor component styling.
Prefer these tokens before adding raw colors, spacing, focus rings, breakpoints, or animation values. If a new value is needed, add it to the token layer first and document its semantic role here in the same PR. For surface hierarchy and shadow use, see docs/ELEVATION.md, and check the Design System Roadmap for active token deprecations and planned components.
This section documents how a ThemePreference ("system" | "light" | "dark")
actually gets from storage to the rendered page. The token catalog above is
what the tokens are; this is how the app picks which values apply.
lib/config/theme.ts— theThemePreferencetype, theTHEME_STORAGE_KEY("theme-preference") it's persisted under inlocalStorage, and theisThemePreferenceguard used to validate whatever comes back out of storage (never trust it blindly -- it can be corrupted, stale from an older schema, or absent).lib/context/ThemeContext.tsx—ThemeProvider/useTheme. Owns the current preference in React state, seeded fromlocalStorageon mount, and exposessetTheme(writes through tolocalStorageand updates state).- The
<html>element's class list —.dark/.light(or neither, for the OS-driven case). This, not React state, is what the CSS custom properties in:root/html.dark/html.lightactually key off of. SeeapplyThemePreference()inThemeContext.tsx. - The inline script in
app/layout.tsx— a second, hand-duplicated copy of the same class-resolution logic asapplyThemePreference, injected as a raw<script>and run before React hydrates.
The inline script in app/layout.tsx exists purely to avoid a flash of the
wrong theme: without it, the page would first paint with no theme class (the
server has no access to localStorage), then flip to the right one only
once ThemeProvider's effect runs after hydration. Running the class
resolution synchronously, before paint, eliminates that flash.
This means app/layout.tsx's inline script and ThemeContext.tsx's
applyThemePreference must be kept in sync by hand -- there is no shared
module between them (the inline script can't import anything; it has to be
a self-contained string). If you change how a preference resolves to a class
in one, change it in the other in the same PR, or the pre-hydration paint and
the post-hydration state will disagree.
Choosing "system" doesn't just resolve prefers-color-scheme once.
ThemeContext.tsx's effect subscribes to the media query's change event
for as long as theme === "system", so toggling the OS theme while the app
is open updates the page immediately, with no reload. Switching away from
"system" unsubscribes (the effect's cleanup runs on the next theme
change).
All current CSS custom properties are declared in app/globals.css.
| Property | Default value | Dark-mode value | Semantic role | Current usage |
|---|---|---|---|---|
--foreground-rgb |
0, 0, 0 |
255, 255, 255 |
Global foreground text color expressed as RGB channels for rgb(var(...)) composition. |
body { color: rgb(var(--foreground-rgb)); } |
--background-start-rgb |
214, 219, 220 |
0, 0, 0 |
Legacy page-gradient start channel. Keep documented because it is part of the global theme contract, even though the current body background is plain black. | Not currently referenced outside declaration. |
--background-end-rgb |
255, 255, 255 |
0, 0, 0 |
Legacy page-gradient end channel. Keep documented with --background-start-rgb for future gradient restoration. |
Not currently referenced outside declaration. |
--background |
Not declared | #010101 |
Dark application canvas. Use for full-page dark surfaces when a CSS variable is required instead of a Tailwind class. | Declared for dark mode; not currently referenced outside declaration. |
--color-bg2 |
Not declared | #0f0f0f |
Upper layer of the dark card gradient. | Used by --card. |
--color-bg3 |
Not declared | #0a0a0a |
Lower layer of the dark card gradient. | Used by --card. |
--card |
Not declared | linear-gradient(var(--color-bg2), var(--color-bg3)) |
Reusable dark card background gradient. | Declared for card-like surfaces that need a CSS variable. |
--accent |
Not declared | #dc2626 |
Primary red accent for dark-mode UI emphasis. Prefer Tailwind brand.red or red.600 in JSX unless CSS needs a variable. |
Declared for CSS-level accent styling. |
--skeleton-base |
rgba(0, 0, 0, 0.06) |
rgba(255, 255, 255, 0.05) |
Resting fill of an animated skeleton placeholder, and the two ends of its shimmer gradient. | .rw-skeleton--shimmer |
--skeleton-highlight |
rgba(0, 0, 0, 0.12) |
rgba(255, 255, 255, 0.1) |
Travelling highlight at the midpoint of the shimmer gradient. | .rw-skeleton--shimmer |
--skeleton-static |
rgba(0, 0, 0, 0.1) |
rgba(255, 255, 255, 0.1) |
Flat fill of a non-animated skeleton placeholder — the static variant, and what the shimmer variant falls back to under prefers-reduced-motion: reduce. Set to the shimmer's highlight value, not its base, so removing the animation does not also make the placeholder fainter. |
.rw-skeleton |
The active global body styles are intentionally minimal:
body {
color: rgb(var(--foreground-rgb));
background: black;
}Tailwind tokens are defined under theme.extend in tailwind.config.js.
Use them through class names so reviewers can distinguish semantic values from
one-off styling.
| Token | Value | Semantic role | Example |
|---|---|---|---|
320 |
320px |
Smallest supported mobile viewport, including iPhone SE. | 320:px-6 |
375 |
375px |
Primary mobile target for modern phones. | 375:text-base |
450 |
450px |
Foldables and larger phones before tablet layout. | 450:grid-cols-2 |
tablet |
768px |
Tablet portrait layouts. | tablet:grid-cols-3 |
laptop |
1024px |
Tablet landscape and small laptop layouts. | laptop:px-8 |
desktop |
1440px |
Full desktop layout width. | desktop:text-5xl |
| Token | Value | Semantic role | Example |
|---|---|---|---|
space-xs |
4px |
Small internal gaps, icon/text spacing. | gap-space-xs |
space-sm |
8px |
Compact stacked controls or labels. | space-y-space-sm |
space-md |
16px |
Default component padding or list spacing. | p-space-md |
space-lg |
24px |
Section-level spacing inside cards or panels. | gap-space-lg |
space-xl |
32px |
Large section spacing. | py-space-xl |
3.5 |
14px |
Fine-grained padding and control spacing. | py-3.5 |
7 |
28px |
Mobile layout spacing between default 6 and 8. |
375:gap-7 |
9 |
36px |
Large but not full 10 spacing. |
tablet:gap-9 |
11 |
44px |
Minimum accessible touch target dimension. | h-11 w-11 |
13 |
52px |
Large icon-button or panel spacing. | h-13 |
15 |
60px |
Large vertical rhythm. | py-15 |
17.5 |
70px |
Fine-grained hero/section spacing. | pt-17.5 |
22.5 |
90px |
Wide section spacing. | py-22.5 |
27.5 |
110px |
Largest custom section spacing. | py-27.5 |
| Token | Value | Semantic role | Example |
|---|---|---|---|
ring-focus |
3px |
Accessible focus ring width for important controls. | focus-visible:ring-focus |
ring-offset-focus |
4px |
Focus ring separation from dark surfaces. | focus-visible:ring-offset-focus |
| Token | Value | Semantic role | Example |
|---|---|---|---|
brand.red |
#D72323 |
Primary RemitWise action and brand accent. | bg-brand-red |
brand.dark |
#0A0A0A |
Brand dark canvas. | bg-brand-dark |
brand.redHover |
#B91C1C |
Hover state for primary red actions. | hover:bg-brand-redHover |
primary.50 |
#f0f9ff |
Lightest informational blue. | bg-primary-50 |
primary.100 |
#e0f2fe |
Very light informational blue. | bg-primary-100 |
primary.200 |
#bae6fd |
Light informational blue. | bg-primary-200 |
primary.300 |
#7dd3fc |
Soft informational blue. | text-primary-300 |
primary.400 |
#38bdf8 |
Medium informational blue. | text-primary-400 |
primary.500 |
#0ea5e9 |
Default informational blue. | text-primary-500 |
primary.600 |
#0284c7 |
Strong informational blue. | bg-primary-600 |
primary.700 |
#0369a1 |
Dark informational blue. | bg-primary-700 |
primary.800 |
#075985 |
Darker informational blue. | bg-primary-800 |
primary.900 |
#0c4a6e |
Darkest informational blue. | bg-primary-900 |
red.600 |
#DC2626 |
Default Tailwind-compatible red action color. | bg-red-600 |
red.700 |
#B91C1C |
Red hover/pressed state. | hover:bg-red-700 |
red.800 |
#991B1B |
Strong red surface or pressed state. | bg-red-800 |
red.900 |
#7F1D1D |
Deep red surface. | bg-red-900 |
Status tokens are grouped by foreground, background, border, and soft surface. Use these instead of hand-rolled status colors so success, warning, error, and info states remain consistent.
| Token | Value | Semantic role | Example |
|---|---|---|---|
status.success.fg |
#86EFAC |
Success text or progress fill. | text-status-success-fg |
status.success.bg |
rgba(34, 197, 94, 0.14) |
Success badge or panel background. | bg-status-success-bg |
status.success.border |
rgba(34, 197, 94, 0.28) |
Success border. | border-status-success-border |
status.success.soft |
rgba(20, 83, 45, 0.28) |
Deeper success surface. | bg-status-success-soft |
status.warning.fg |
#FDE68A |
Warning text or icon. | text-status-warning-fg |
status.warning.bg |
rgba(245, 158, 11, 0.14) |
Warning badge or panel background. | bg-status-warning-bg |
status.warning.border |
rgba(245, 158, 11, 0.28) |
Warning border. | border-status-warning-border |
status.warning.soft |
rgba(120, 53, 15, 0.28) |
Deeper warning surface. | bg-status-warning-soft |
status.error.fg |
#FDA4AF |
Error text or icon. | text-status-error-fg |
status.error.bg |
rgba(244, 63, 94, 0.14) |
Error badge or panel background. | bg-status-error-bg |
status.error.border |
rgba(244, 63, 94, 0.28) |
Error border. | border-status-error-border |
status.error.soft |
rgba(127, 29, 29, 0.3) |
Deeper error surface. | bg-status-error-soft |
status.info.fg |
#93C5FD |
Informational text or icon. | text-status-info-fg |
status.info.bg |
rgba(59, 130, 246, 0.14) |
Informational badge or panel background. | bg-status-info-bg |
status.info.border |
rgba(59, 130, 246, 0.28) |
Informational border. | border-status-info-border |
status.info.soft |
rgba(30, 64, 175, 0.24) |
Deeper informational surface. | bg-status-info-soft |
| Token | Value | Semantic role | Example |
|---|---|---|---|
animate-neon-pulse |
neon-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite |
Pulsing emphasis for neon/accent elements. | animate-neon-pulse |
animate-shimmer |
shimmer 2s linear infinite |
Skeleton or loading shimmer. | animate-shimmer |
animate-slide-in-right |
slide-in-right 0.25s ease-out forwards |
Drawer or toast entrance from the right. | animate-slide-in-right |
animate-slide-in-bottom |
slide-in-bottom 0.25s ease-out forwards |
Mobile sheet entrance from the bottom. | animate-slide-in-bottom |
These utilities are defined in app/globals.css.
| Utility | CSS output | Semantic role |
|---|---|---|
.starry-bg |
Repeating radial dot background at 40px size. |
Decorative dark-page background. |
.safari-safe-top |
padding-top: env(safe-area-inset-top) |
Respect iOS top safe area. |
.safari-safe-bottom |
padding-bottom: env(safe-area-inset-bottom) |
Respect iOS bottom safe area. Requires viewport-fit=cover (set globally in app/layout.tsx) or the inset reads as 0px. Elements with existing base padding should use the calc(theme(spacing.N)+env(...)) pattern instead — see docs/tailwind-extensions.md. |
.safari-safe-left |
padding-left: env(safe-area-inset-left) |
Respect iOS left safe area. |
.safari-safe-right |
padding-right: env(safe-area-inset-right) |
Respect iOS right safe area. |
.touch-target |
min-height: 44px; min-width: 44px |
Minimum accessible square touch target. |
.touch-target-wide |
min-height: 44px; min-width: 88px |
Minimum accessible wide touch target for buttons. |
Two further classes are emitted into Tailwind's components layer, so any
utility passed in className still wins over them. Apply them through the
<Skeleton /> component rather than by hand — see docs/COMPONENTS.md.
| Class | CSS output | Semantic role |
|---|---|---|
.rw-skeleton |
background-color: var(--skeleton-static) |
Static skeleton placeholder; never animates. |
.rw-skeleton--shimmer |
Shimmer gradient from the --skeleton-* tokens, animated by rw-skeleton-shimmer. Reset to the static fill under prefers-reduced-motion: reduce. |
Animated skeleton placeholder. |
- Use CSS custom properties only for global CSS values that must exist outside Tailwind class composition.
- Use Tailwind token classes for component styling in JSX.
- Keep status UI on
status.*tokens. - Keep primary actions on
brand.red,brand.redHover, or the local red scale. - Do not introduce new hard-coded colors, spacing, radii, or focus values in a component when a token already represents the same role.
- Update this file when
app/globals.cssortailwind.config.jsadds, removes, or changes theme tokens. - For contrast ratio requirements and how to verify new colour tokens, see docs/SEMANTIC_TOKENS_AND_CONTRAST.md.