Theming

Six built-in themes across three families, how to switch at runtime, and how to write a theme of your own.

PanelUI ships three theme families, each in a light and a dark form. A family sets its own radius scale as well as its own palette, so switching one restyles the shape of the UI, not just its colour.

FamilyLightDarkCharacter
PanellightdarkThe default — neutral greys, moderate corners
Moonmoonmoon-darkNear-black canvas, lavender accent, elevation by hairline
Grassgrassgrass-darkGreen accent on warm neutrals, soft generous corners

What each theme actually sets is the token list on Colors. This page is about choosing between them and adding your own.

Registering the themes

light and dark work everywhere. The other four must be listed in extraThemes in your Metro config, or setTheme('moon') throws "it was not registered".

metro.config.js
module.exports = withUniwindConfig(config, {
  cssEntryFile: './src/global.css',
  extraThemes: ['moon', 'moon-dark', 'grass', 'grass-dark'],
});

PANEL_EXTRA_THEMES is exported with exactly that list, if you would rather not keep two copies in step.

A change to extraThemes needs the dev server restarted, not reloaded. A running server rewrites the generated CSS from the theme list it started with, so a newly added theme appears to be registered and still is not.

Where the tokens come from

The themes live in a stylesheet, and which one you import depends on how you installed the library. Both give you the same six themes; they differ only in whether the file is yours to edit.

Installed as a dependency, the stylesheet is read straight out of it. Upgrading the package upgrades the themes, and there is no copy of them to fall behind.

global.css
@import 'tailwindcss';
@import 'uniwind';
@import 'panelui-native/theme.css';

@source './node_modules/panelui-native/src';

panelui-cli init copies theme.css into your project instead, next to the components it copies. It is a file you own, so edit the tokens in place — but an upgrade will not touch it, which is the trade.

global.css
@import 'tailwindcss';
@import 'uniwind';
@import './theme.css';

@source './components';

The @source line is not optional in either shape. It tells Tailwind where to look for the class names the components use; without it the components render with no styles at all, which reads like a broken install rather than a missing line.

Switching at runtime

import { useTheme, useThemeMode, PANEL_THEMES } from 'panelui-native';

// By name
const { theme, setTheme } = useTheme();
setTheme('moon-dark');
setTheme('system'); // follow the device

// Or as two independent axes
const { family, mode, setFamily, setMode, toggleMode } = useThemeMode();
toggleMode();          // dark ↔ light, staying in the current family
setFamily('grass');    // switch family, staying in the current mode

Uniwind applies the change natively without re-rendering the tree, so a theme switch costs nothing beyond the repaint.

Why there are two hooks

Uniwind gives prefers-color-scheme handling to light and dark and to nothing else — any other theme compiles to a plain class selector and cannot adapt on its own. Each family therefore ships as a light/dark pair, and useThemeMode treats family and mode as separate axes so a sun toggle keeps working inside a brand theme. Without it, someone in Moon dark who taps the sun lands in default light rather than in Moon light.

PANEL_THEMES is the list of families, each with a display name and a representative swatch colour for both modes — enough to build a theme picker without hardcoding anything.

{PANEL_THEMES.map((entry) => (
  <Pressable key={entry.id} onPress={() => setFamily(entry.id)}>
    <View style={{ backgroundColor: entry.swatch[mode === 'dark' ? 1 : 0] }} />
    <Text>{entry.name}</Text>
  </Pressable>
))}

A theme picker, in full

Nothing here is specific to the themes that ship — a family you write yourself appears in the row as soon as it is in PANEL_THEMES, because the row is generated from that list rather than from a hardcoded three.

import { Pressable, View } from 'react-native';
import { PANEL_THEMES, Text, useThemeMode } from 'panelui-native';

export function ThemePicker() {
  const { family, mode, setFamily, toggleMode } = useThemeMode();

  return (
    <View className="gap-4">
      <View className="flex-row gap-3">
        {PANEL_THEMES.map((entry) => {
          const selected = entry.id === family.id;
          return (
            <Pressable
              key={entry.id}
              accessibilityRole="radio"
              accessibilityState={{ selected }}
              accessibilityLabel={entry.name}
              onPress={() => setFamily(entry.id)}
              className="items-center gap-2"
            >
              <View
                className={selected ? 'rounded-full border-2 border-ring p-0.5' : 'p-0.5'}
              >
                <View
                  className="h-9 w-9 rounded-full border border-border"
                  style={{ backgroundColor: entry.swatch[mode === 'dark' ? 1 : 0] }}
                />
              </View>
              <Text size="sm" muted={!selected}>
                {entry.name}
              </Text>
            </Pressable>
          );
        })}
      </View>

      <Button variant="outline" onPress={toggleMode}>
        {mode === 'dark' ? 'Light' : 'Dark'}
      </Button>
    </View>
  );
}

setFamily keeps the current mode and toggleMode keeps the current family, so the two controls are genuinely independent: someone in Moon dark who taps the sun lands in Moon light, not in the default light.

Reading a token in JavaScript

Tailwind classes cover styling. Anything that takes a colour as a value — an SVG stroke, a React Navigation theme, a status bar, a native control — needs the resolved string, and useCSSVariable gives it. It re-resolves on a theme change, so the value follows the theme without a re-render of the tree above it.

import { useCSSVariable } from 'uniwind';

const border = useCSSVariable('--color-border');

// It is `undefined` until the stylesheet has been read, so a component that
// paints on the first frame needs a fallback rather than a crash.
<Svg><Path stroke={typeof border === 'string' ? border : 'rgba(128,128,128,0.2)'} /></Svg>

That guard is the idiom throughout the library — every chart resolves its grid and series colours this way.

Radius

--radius-xs through --radius-3xl are themed, and that is what gives each family its shape. Use rounded-md, rounded-xl and friends as normal — the values move with the theme.

TokenPanelMoonGrass
--radius-xs4px4px6px
--radius-sm6px6px8px
--radius-md8px8px12px
--radius-lg10px12px14px
--radius-xl14px16px18px
--radius-2xl16px24px22px
--radius-3xl24px32px28px

The three scales diverge at the top, not the bottom. A 4px and a 6px corner are nearly the same corner; a 16px and a 32px one are two different products. So a badge looks much the same in every family and a sheet does not, which is the right way round — the shapes that carry a family's character are the large ones.

Override them the way you would override a colour, and every rounded corner in the library follows.

Writing a theme

A theme is a @variant block that defines every token. Start by copying an existing one rather than from nothing — the build fails on the first token you miss, and the error names the rule rather than the token.

global.css
@import 'tailwindcss';
@import 'uniwind';
@import 'panelui-native/theme.css';

@source './node_modules/panelui-native/src';

@custom-variant sunset (&:where(.sunset, .sunset *));

@layer theme {
  :root {
    @variant sunset {
      --color-background: #fffaf5;
      --color-foreground: #2b1a12;
      /* …every remaining token… */
    }
  }
}

Then add sunset to extraThemes and restart the server.

The whole block

All sixty-one tokens, in the order the built-in themes use them, so this can be pasted and edited rather than assembled. Values here are the light half of a warm neutral family; the dark half is the same list with the same names.

global.css
@variant sunset {
  /* surfaces, and what sits on them */
  --color-background: #fffaf5;
  --color-foreground: #2b1a12;
  --color-card: #ffffff;
  --color-card-foreground: #2b1a12;
  --color-popover: #ffffff;
  --color-popover-foreground: #2b1a12;
  --color-overlay: #ffffff;
  --color-overlay-foreground: #2b1a12;
  /* the band under a card or dialog footer — always darker than the surface */
  --color-inset: rgba(43, 26, 18, 0.035);

  /* actions */
  --color-primary: #c2410c;
  --color-primary-foreground: #ffffff;
  --color-secondary: rgba(43, 26, 18, 0.06);
  --color-secondary-foreground: #2b1a12;
  --color-muted: rgba(43, 26, 18, 0.06);
  --color-muted-foreground: #7c6659;
  --color-accent: rgba(43, 26, 18, 0.07);
  --color-accent-foreground: #2b1a12;

  /* status */
  --color-destructive: #dc2626;
  --color-destructive-foreground: #b91c1c;
  --color-info: #2563eb;
  --color-info-foreground: #1d4ed8;
  --color-success: #16a34a;
  --color-success-foreground: #15803d;
  --color-warning: #d97706;
  --color-warning-foreground: #b45309;

  /* lines */
  --color-border: rgba(43, 26, 18, 0.1);
  --color-input: rgba(43, 26, 18, 0.14);
  --color-ring: #c2410c;

  /* elevation */
  --color-surface: #fdf5ec;
  --color-surface-secondary: #faeee1;
  --color-surface-tertiary: #f5e5d3;
  --color-skeleton: rgba(43, 26, 18, 0.08);

  /* tinted fills — `soft` is the stronger of the pair */
  --color-info-soft: rgba(37, 99, 235, 0.1);
  --color-info-subtle: rgba(37, 99, 235, 0.07);
  --color-success-soft: rgba(22, 163, 74, 0.1);
  --color-success-subtle: rgba(22, 163, 74, 0.07);
  --color-warning-soft: rgba(217, 119, 6, 0.12);
  --color-warning-subtle: rgba(217, 119, 6, 0.08);
  --color-destructive-soft: rgba(220, 38, 38, 0.1);
  --color-destructive-subtle: rgba(220, 38, 38, 0.07);

  /* syntax */
  --color-code-keyword: #9333ea;
  --color-code-string: #15803d;
  --color-code-number: #1d4ed8;
  --color-code-comment: #9a8578;
  --color-code-function: #7c3aed;
  --color-code-property: #b45309;
  --color-code-punctuation: #7c6659;
  --color-code-inserted: #15803d;
  --color-code-deleted: #b91c1c;

  /* chart series, in order of prominence */
  --color-chart-1: #c2410c;
  --color-chart-2: #ea580c;
  --color-chart-3: #f59e0b;
  --color-chart-4: #16a34a;
  --color-chart-5: #2563eb;

  /* shape */
  --radius-xs: 4px;
  --radius-sm: 6px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --radius-2xl: 20px;
  --radius-3xl: 28px;
}

Two things worth getting right

Ship both halves. Uniwind gives prefers-color-scheme handling to light and dark and to nothing else, so a family with only one half cannot follow the device and breaks the sun toggle for anyone who lands in it — see why there are two hooks. Write sunset and sunset-dark, and add both to extraThemes.

Values must be static. These are read by React Native, which cannot evaluate color-mix() or --alpha() at runtime. Precompute to a literal rgba() or hex. The same tokens on the web can keep the expressions.

Only the four remaining values are worth thinking about for long. Pick --color-background, --color-foreground, --color-primary and --color-border first, then derive the rest from them — the surfaces are steps away from the background, the tinted fills are their status colour at 7–12% alpha, and muted-foreground is whatever clears 4.5:1 against the background.

Two rules Uniwind enforces

Every theme must define the same variables. Adding a token to one theme without adding it to all of them fails the build with "All themes must have the same variables".

The web's :root { --x } / .dark { --x } pattern does not work. Uniwind resolves themed tokens only from @variant blocks, and light-dark() inside @theme breaks colour parsing outright.

Why theme.css declares its own variants

panelui-native/theme.css carries a @custom-variant line for each of the four named themes, which looks redundant next to extraThemes. It is not.

The variants Uniwind generates from extraThemes live in an artifact it produces by compiling theme.css — so on a fresh install, at the moment moon was first used, nothing had defined it yet. The dev server got away with it because it registers themes in memory; a production bundle failed with Cannot use @variant with unknown variant. Declared in the file itself the cycle does not exist, and extraThemes goes back to meaning only what it reads like it means: which themes can be switched to at runtime.

Next

On this page