Dark Mode
Add light and dark mode to your app.
Appica UI is dark-mode-ready out of the box. The token system ships both a light
and a dark palette; switching themes is a matter of toggling a class on the
<html> element, which ThemeProvider manages for
you - including persistence and a no-flash first paint.
How it works
Dark mode is class-based. When the dark class is on a <html> ancestor,
the dark token values apply. The library defines the variant as:
So you can write dark-only utilities in your own markup too:
Setup
Wrap your app in ThemeProvider at your app's root (covered in
Installation). It applies the correct class before
the page paints, so there's no flash of the wrong theme on load. The example
uses Next.js's root layout - see the
Framework notes for where this file
lives in Vite, React Router / Remix, TanStack Start, and Astro:
suppressHydrationWarning on <html> is expected: the no-flash script sets the theme class before React
hydrates, so the server and client markup intentionally differ by that one attribute.
By default ThemeProvider follows the OS preference (enableSystem) and persists
the user's choice under the theme key in localStorage.
Building a theme toggle
Read and set the theme with useTheme. Guard
theme-dependent UI with mounted so the server-rendered output matches the first
client render:
themeis the stored choice, which may be'system'.resolvedThemeis what's actually applied -'system'resolved to'light'or'dark'.systemThemeis the current OS preference.
Common options
ThemeProvider accepts several props to tune behavior:
forcedTheme pins the whole app, so it belongs on the provider that owns the
state:
Nested providers are a no-op
Only the outermost ThemeProvider owns the theme. A second one further down the tree returns its children
untouched, so wrapping a route in <ThemeProvider forcedTheme="dark"> under an app-root provider changes nothing
and reports no error. To theme part of a page, use the dark class as below.
Scoped dark regions and portals
To make one region dark inside an otherwise light page, put the class on a
wrapper. The variant is a descendant selector, and .dark sets the token custom
properties, which inherit - so everything underneath flips:
Anything that opens a popup from inside that region is the exception. Select,
DropdownMenu, Tooltip, Dialog and friends portal their popup to <body>, which
lands it outside your wrapper, so it renders in the page's theme rather than the
region's.
React context isn't the problem. createPortal keeps the node in the React
tree, so context crosses it fine. Only CSS inheritance breaks, because the
popup's DOM parent is <body>.
That makes the fix small: the class only has to reach some ancestor of the
popup, and every *Content exposes one.
- Floating components take
positionerProps: Select, DropdownMenu, Tooltip, Popover, Combobox, Autocomplete, ContextMenu, Menubar, PreviewCard. - Modal components take
viewportProps: Dialog, AlertDialog, Drawer.
Both merge your className with their own, so the class is added rather than
replacing anything.
Why not container
Every one of these components also has a flat container prop that moves the
portal target, and pointing it at your dark wrapper looks like the tidier fix. It
isn't, for three reasons:
- Clipping comes back. Portaling to
<body>is what lets a popup escape an ancestor'soverflow: hidden. Aim it at a rounded panel and you get the clipping back. - Stacking changes. Popups are
z-50, which inside a container is relative to that container's stacking context rather than the page. - It breaks Dialog and Drawer outright. Their backdrop and viewport are
position: fixed. Any ancestor withtransform,filter,backdrop-filter,containorwill-changebecomes the containing block, and the full-screen overlay collapses to that container's box. These surfaces usebackdrop-blurand Motion writes transforms, so the trigger is common rather than exotic.
container has a real purpose - shadow DOM, an iframe, a deliberate stacking
context - covered in Composition.
Theming isn't it.
`resolvedTheme` reports the global theme
useTheme().resolvedTheme reads the provider, not the region a component sits in, so inside a scoped .dark
wrapper it still returns the page's theme. Styling is unaffected, since that runs on CSS. Anything that branches
in JavaScript on the theme - a chart palette, a theme-specific image - will pick the wrong one there, so pass the
region's theme down explicitly instead.
See ThemeProvider for the full prop reference and
useTheme for the hook.