Skip to content

PRD: Dark Mode Toggle

Worked example for the Notes web app (React + TypeScript, bun). Source of truth for what and why. Produced by idea-to-prd.

  • Problem Statement: Notes renders in a single permanently-light theme. Readers who use the app in low light report eye strain, and a dark theme is the most-requested item in recent feedback. There is no theming layer today.
  • Proposed Solution: Introduce a light/dark theme built on CSS custom properties, a user-visible toggle on every page, and a persisted preference that defaults to the operating system setting. Apply the resolved theme before first paint so the page never flashes the wrong colors.
  • Success Criteria:
    1. A user can switch themes in one interaction from any page.
    2. The chosen theme persists across reloads and sessions (0 resets on refresh).
    3. No flash of the wrong theme on load — first paint matches the resolved theme.
    4. With no stored choice, the app matches the OS prefers-color-scheme on 100% of first visits.
    5. Every text/background pair in the dark theme meets WCAG 2.1 AA contrast (≥ 4.5:1 normal text).
  1. Provide a light and a dark theme with a visible toggle control.
  2. Persist the user’s choice locally and restore it on later visits.
  3. Default to the OS theme when the user has not chosen one.
  4. Eliminate the flash of incorrect theme (FOUC) on initial load.
  5. Meet WCAG 2.1 AA contrast in both themes.
  • We will not add per-component, custom, or user-authored color themes — only light, dark, and “follow system.”
  • We will not store the preference server-side or sync it across devices (no backend change).
  • We will not add automatic time-of-day theme switching.
  • We will not theme exported/printed output or transactional email.
  • Must use the existing React + TypeScript + bun stack; no new state-management or theming dependency.
  • Must work entirely client-side — Notes has no per-user backend store for settings.
  • Must support the project’s existing browser matrix (last two major versions of evergreen browsers).

One cohesive feature, six functional requirements, no subsystem split needed. A single PRD is appropriate.

  • Night reader — a returning user who reads notes in low-light settings and wants a comfortable dark UI.
  • System-preference user — has set a dark mode at the OS level and expects apps to honor it without extra configuration; may rely on sufficient contrast.
  • US-1 (Night reader): As a reader, I want to switch to a dark theme so I can read comfortably at night. Priority: P0
    • Acceptance Criteria:
      • A theme toggle is visible on every page.
      • Activating it switches the theme immediately, with no reload.
      • The choice persists after a full page reload and in a new session.
  • US-2 (System-preference user): As a user with an OS dark-mode setting, I want Notes to default to my system theme so I don’t have to configure it. Priority: P1
    • Acceptance Criteria:
      • On a first visit with no stored choice, the app renders the OS theme.
      • An explicit toggle choice overrides the OS default and is remembered.
  • FR-1: The app must present a theme toggle control reachable from every page. (P0)
  • FR-2: The app must apply the selected theme (light or dark) to all UI surfaces. (P0)
  • FR-3: The app must persist the user’s theme choice locally and restore it on subsequent loads. (P0)
  • FR-4: When no choice is stored, the app must resolve the theme from the OS prefers-color-scheme. (P1)
  • FR-5: The app must apply the resolved theme before first paint, so no flash of the wrong theme occurs. (P0)
  • FR-6: The toggle must reflect the current theme and be fully operable by keyboard and screen reader. (P1)
  • NFR-1 (Performance): The pre-paint theme resolution must run in < 5 ms and add < 1 KB to the initial HTML.
  • NFR-2 (Accessibility): All foreground/background text pairs must meet WCAG 2.1 AA contrast (≥ 4.5:1 normal, ≥ 3:1 large) in both themes; the toggle must satisfy WCAG 2.1 AA for keyboard and screen-reader operation.
  • NFR-3 (Compatibility/Resilience): If localStorage is unavailable (e.g. private mode), the app must fall back to the OS theme without error.

Define both palettes as CSS custom properties keyed off a data-theme attribute on the document root. A tiny inline script in the document head reads the stored preference (or the OS media query) and sets data-theme before the stylesheet paints — this is what prevents the flash. A small React context (ThemeProvider) holds the resolved theme for the UI, and the toggle writes the choice back to storage and updates the attribute. This reuses the project’s existing CSS-variable design tokens rather than introducing a theming library.

  • Decision: Persist the preference in localStorage, applied via an inline pre-paint script.
    • Context: The preference must survive reloads and be readable before the React app mounts.
    • Options Considered: localStorage + inline script; cookie; server-side user setting.
    • Rationale: Notes is a client-rendered SPA with no per-user settings backend; localStorage is readable synchronously before paint and needs no network or backend work.
    • Trade-offs: Preference is per-device, not synced across devices (acceptable per non-goals).
    • The full rationale and rejected alternatives are recorded as an ADR during design — see design.md and adr/0001-theme-persistence.md.

The toggle and provider live in the existing component tree; the inline script lives in index.html. The only new “data” is a single string key in localStorage. No new services or dependencies are introduced.

  • Reuse the existing CSS-variable token layer; add a second palette rather than a new styling system.
  • Keep storage access behind one small module so the fallback (NFR-3) lives in one place.

No authentication, authorization, or PII is involved. The only persisted value is a non-sensitive theme string written to localStorage. Input is constrained to the enum light | dark | system. Security is not a primary concern for this feature.

  • CSS-only prefers-color-scheme (no toggle)
    • Pros: Zero JavaScript; honors the OS automatically.
    • Cons: Cannot honor an explicit user override (fails US-1).
    • Verdict: Rejected — an explicit, persisted toggle is a core requirement.
  • A CSS-in-JS theming library
    • Pros: Rich theming API out of the box.
    • Cons: New runtime dependency; duplicates the existing CSS-variable tokens.
    • Verdict: Rejected — the token layer already does what we need.
  • Phase 1 (MVP): Token palettes, ThemeProvider, the toggle, persistence, and the pre-paint script. Delivers US-1, US-2, and FR-1–FR-5.
  • Phase 2 (Accessibility & polish): Keyboard/screen-reader support for the toggle and a dark-palette contrast audit. Delivers FR-6 and NFR-2.
  • React + TypeScript components following the existing src/components/ conventions; bun for all scripts. No new libraries.
  • Purely additive. No data migration. Default behavior for existing users matches their OS theme until they choose otherwise.
  • Unit: theme resolution (stored → OS → default), storage read/write, and the localStorage-unavailable fallback.
  • Integration: toggling flips data-theme and writes storage; the value is restored on remount.
  • End-to-End / regression: a test asserting the initial data-theme is set before app mount (guards FR-5 against regression).
  • Use the project’s existing Vitest setup and the standard test locations. All existing checks must continue to pass.
  • QG-1: bun run check — type checking passes with zero errors
  • QG-2: bun run format — formatting matches project standards
  • QG-3: bun run lint — no lint warnings or errors
  • QG-4: bun run test — all existing and new tests pass
  • QG-5: bun run build — build completes successfully
  • QG-6: Code review completed
RiskLikelihoodImpactMitigation
Inline script placed after the stylesheet → flash of wrong themeMedHighPlace the script in <head> before the stylesheet; add a regression test asserting the initial data-theme (FR-5).
localStorage blocked (private mode) throws on read/writeLowMedWrap access in try/catch; fall back to the OS theme (NFR-3).
Dark palette fails AA contrast on some surfacesMedMedDedicated contrast-audit task before release (NFR-2).
  1. Two-state or three-state toggle? Should the control be light↔dark, or expose an explicit “System” option as a third state?
    • Owner: PM.
    • Impact: Affects the toggle UI and one storage value (system).
    • Proposed default: Ship a two-state toggle that defaults to the OS theme until the user chooses; revisit an explicit “System” option if requested. Tracked downstream as a Future Consideration.
  • Glossary: FOUC — flash of unstyled (here, wrong-themed) content; token — a named CSS custom property such as --color-bg.
  • Related: design.md · tasks.md