Appica UIUI 1.1.0

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:

app/layout.tsx (Next.js)

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:

theme-toggle.tsx
  • theme is the stored choice, which may be 'system'.
  • resolvedTheme is what's actually applied - 'system' resolved to 'light' or 'dark'.
  • systemTheme is 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:

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:

  1. Clipping comes back. Portaling to <body> is what lets a popup escape an ancestor's overflow: hidden. Aim it at a rounded panel and you get the clipping back.
  2. Stacking changes. Popups are z-50, which inside a container is relative to that container's stacking context rather than the page.
  3. It breaks Dialog and Drawer outright. Their backdrop and viewport are position: fixed. Any ancestor with transform, filter, backdrop-filter, contain or will-change becomes the containing block, and the full-screen overlay collapses to that container's box. These surfaces use backdrop-blur and 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.

See ThemeProvider for the full prop reference and useTheme for the hook.

© 2026 Appica UI. A free component library, crafted by the Appica team.