React Native Theme TransitionReact Native Theme Transition
API

useTheme

Read the painted theme, the user's preference, the mutator, and the transition flag from the nearest provider.

useTheme returns the painted theme, the user's preference, the setTheme mutator, and an isTransitioning flag. It throws if called outside a ThemeTransitionProvider.

Reference

const { theme, preference, setTheme, isTransitioning } = useTheme()

The hook is created by createThemeTransition and is fully typed against your theme map. theme.name, theme.colors, and setTheme's argument all narrow to the names you defined in themes.

Returns

FieldTypeDescription
theme.nameNamesThe theme currently painted on screen. Always concrete. Never 'system'.
theme.colorsRecord<Token, string>Resolved color tokens for the painted theme.
theme.scheme'light' | 'dark'Binary classification, derived from darkThemes.
preferenceNames | 'system'What the user explicitly picked. Mirrors the last value passed to setTheme.
isTransitioningbooleantrue while a transition overlay is visible.
setTheme(name, opts?) => 'accepted' | 'ignored'Change the user's preference. See setTheme below.

Theme vs Preference

The hook exposes two related but distinct values, and choosing the right one matters.

  • theme describes what is currently painted on screen. theme.name is always concrete (never 'system'). When the user picks 'system', the library resolves it via systemThemeMap and exposes the result here.
  • preference describes what the user explicitly picked. It can be 'system'. It mirrors the last argument passed to setTheme.

Decision Table

You want toReadWhy
Style a componenttheme.colors.*Real values, resolved.
StatusBar content style and isDark checkstheme.schemeBinary, regardless of theme count.
Toggle dark to lighttheme.nameAlways concrete, works in system mode.
Per-theme asset (e.g. logos[name])theme.nameAlways a real theme key.
Analytics for what was renderedtheme.nameThe painted theme.
Theme picker highlightpreferenceMust be able to show 'system' as selected.
Analytics for what the user pickedpreferenceCarries intent, including 'system'.
Persist the user's choicepreferenceRestore the same intent on the next launch.

Common Mistakes

Don't compare picker rows against theme.name.

// Wrong. theme.name is always concrete; the 'system' row never lights up.
options.map((option) => (
  <Pressable style={{ opacity: theme.name === option ? 1 : 0.5 }} />
))

Do compare them against preference.

// Right. preference can be 'system'.
options.map((option) => (
  <Pressable style={{ opacity: preference === option ? 1 : 0.5 }} />
))

Don't toggle dark to light against preference.

// Wrong. When the user is in system mode, preference === 'system'
// and this branch sends them to 'light' even if the OS is currently dark.
setTheme(preference === 'dark' ? 'light' : 'dark')

Do toggle against theme.name.

// Right. theme.name tracks what's painted, even in system mode.
setTheme(theme.name === 'dark' ? 'light' : 'dark')

Example

components/Card.tsx
import { Pressable, Text, View } from 'react-native'
import { useTheme } from '@/lib/theme'

export function Card() {
  const { theme, preference, setTheme, isTransitioning } = useTheme()

  return (
    <View style={{ backgroundColor: theme.colors.background, padding: 16 }}>
      <Text style={{ color: theme.colors.text }}>
        Painted: {theme.name}
      </Text>
      <Text style={{ color: theme.colors.textSecondary }}>
        Preference: {preference}
      </Text>
      <Pressable
        disabled={isTransitioning}
        onPress={() => setTheme(theme.name === 'dark' ? 'light' : 'dark')}
      >
        <Text style={{ color: theme.colors.primary }}>Toggle</Text>
      </Pressable>
    </View>
  )
}

setTheme

setTheme(name, options?): 'accepted' | 'ignored'

Changes the user's preference. The new preference is reflected synchronously in subsequent reads of preference, so picker UIs that highlight against it can repaint before the snapshot is captured.

Arguments

NameTypeDescription
nameNames | 'system'Concrete theme name, or 'system' to follow the OS appearance.
optionsSetThemeOptionsOptional per-call configuration. See SetThemeOptions for every variant and field.

Returns

ValueMeaning
'accepted'The library will apply the change, instantly or via an animated transition.
'ignored'A transition is already in flight, or the call targets the same preference that's already active.

Most callers can discard the return value. isTransitioning and the configured callbacks cover the common cases. The discriminated string is there for telemetry callers who want to know explicitly whether a call did anything.

Common Options

// Default fade.
setTheme('dark')

// Circular reveal from a button ref.
setTheme('dark', { transition: 'circularReveal', origin: buttonRef })

// Wipe right (new theme enters from the left edge).
setTheme('dark', { transition: 'wipe', direction: 'right', duration: 500 })

// Split closing inward like shutters.
setTheme('dark', { transition: 'split', mode: 'top-bottom', inverted: true })

// Pixelize with a chunkier mosaic at the peak.
setTheme('dark', { transition: 'pixelize', blockSize: 40 })

// Instant switch (no snapshot, no overlay).
setTheme('dark', { animated: false })

isTransitioning

true from just after the snapshot is captured until the animation completes. Use it to disable interactive controls so a second call can't be queued mid-transition.

<Pressable disabled={isTransitioning} onPress={() => setTheme('dark')}>
  <Text>Dark</Text>
</Pressable>

Touch input is also blocked internally from the moment setTheme is called, even before isTransitioning flips, since the snapshot hasn't been captured yet. The flag is there for visual feedback like disabled={isTransitioning}. The library protects you from the race.

Picker Pattern

preference updates synchronously inside setTheme, so pickers can highlight against it directly. No local picked state is needed. The engine waits one frame before capturing the snapshot, which gives React time to commit and paint the new highlight.

import { Pressable, Text, View } from 'react-native'
import { useTheme } from '@/lib/theme'

const OPTIONS = ['light', 'dark', 'system'] as const

export function ThemePicker() {
  const { theme, preference, setTheme, isTransitioning } = useTheme()

  return (
    <View style={{ flexDirection: 'row', gap: 8 }}>
      {OPTIONS.map((option) => (
        <Pressable
          key={option}
          disabled={isTransitioning}
          onPress={() => setTheme(option)}
          style={{
            backgroundColor:
              preference === option ? theme.colors.primary : theme.colors.surface,
          }}
        >
          <Text style={{ color: theme.colors.text }}>{option}</Text>
        </Pressable>
      ))}
    </View>
  )
}

See Theme Picker for the persisted-state variation.

Runtime Errors

setTheme validates its numeric options at every call and throws a descriptive Error if any value is out of range. The check runs before the same-theme and in-flight guards, so misuse fails fast at the call site instead of producing a silently broken animation.

ErrorCause
`duration` must be a non-negative finite numberduration is negative, NaN, or Infinity.
`blockSize` must be a finite number >= 2blockSize is below 2, NaN, or Infinity.
`noiseSize` must be a finite number >= 1noiseSize is below 1, NaN, or Infinity.
`origin` coordinates must be finite numbersAn explicit origin point has a NaN or Infinity coordinate. (Refs are validated at measurement time and fall back to the screen center if measurement fails.)

These validations are intentionally strict because the affected values feed shaders and geometry math where silent NaN propagation produces empty or visually broken transitions instead of a useful crash.

Behavior Rules

  1. Same preference. Returns 'ignored'.
  2. Already transitioning. Returns 'ignored'. Wait for isTransitioning to become false before retrying.
  3. 'system'. Enters system-following mode and subscribes to OS changes. preference becomes 'system', while theme.name holds the OS-resolved concrete theme.
  4. Concrete name. Exits system-following mode.
  5. animated: false. Instant switch. Only onThemeChange fires.
  6. Snapshot capture failure. Falls back to an instant switch. onThemeChange still fires, but onTransitionEnd does not.

See Also

On this page