Kinetik UI

Theme Switch

.md

A light and dark switch drawn as a sun whose rays turn and draw in while a shadow slides across to leave a moon.

v1.0.0Live on web@shopify/react-native-skiareact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add theme-switch

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/theme-switch.json

Install manually

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/theme-switch.tsx
    View source files

Usage

import { ThemeSwitch } from '@/components/kinetik/theme-switch';

<ThemeSwitch value={scheme} onValueChange={setScheme} label="Dark mode" />;

// Pass the same value to the provider to switch the whole app:
<KinetikProvider scheme={scheme}>{app}</KinetikProvider>;

When to use

  • A settings row or a header button that switches the app between light and dark.
  • Anywhere a plain switch would need an extra icon to explain itself.

When not to use

  • Three choices (light, dark, follow the system). Use Glide Radio or a segmented control.
  • Settings unrelated to appearance. Use Pulse Toggle.

Notes

  • It is a switch for screen readers: "Dark mode", on or off. With label the words sit beside the icon and name it.
  • The icon is one small Skia canvas: the crescent is a circle cut out of the core in an offscreen layer.
  • With reduced motion the sun becomes the moon at once.

Props

ThemeSwitchProps

PropTypeDefaultDescription
valuerequiredThemeMode—
onValueChangerequired(value: ThemeMode) => void—
labelstring—Words beside the icon, e.g. "Dark mode". Leave out for an icon-only button.
accessibilityLabelstring'Dark mode'Read by screen readers when there is no visible label. Defaults to "Dark mode".
hapticsbooleantrueA tap as it switches. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
A 28 pt Skia canvas driven by one shared value: two circles in a layer and one path of eight rays. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.29× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 153 MB app memory. Steady 22 ms on this emulator for a small Skia canvas; the same run measured Reveal Field (also Skia) at 17 ms.

Real-device measurement pending.

Accessibility

Reduced motion
The icon changes at once.
Screen readers
A switch named "Dark mode" (or by its label) that is on in dark mode.
Touch target
48 by 48 pt, or the whole row with a label.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/theme-switch.tsx
import { Canvas, Circle, Group, Path, Skia } from '@shopify/react-native-skia';
import { useEffect } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  interpolateColor,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type ThemeMode = 'light' | 'dark';

export type ThemeSwitchProps = {
  value: ThemeMode;
  onValueChange: (value: ThemeMode) => void;
  /** Words beside the icon, e.g. "Dark mode". Leave out for an icon-only button. */
  label?: string;
  /** Read by screen readers when there is no visible label. Defaults to "Dark mode". */
  accessibilityLabel?: string;
  /** A tap as it switches. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const BOX = 48;
const ICON = 28;
const C = ICON / 2;

/**
 * A light and dark switch drawn as a sun that becomes a moon. The rays turn a
 * quarter and draw in, the core swells, and a shadow slides across it to
 * leave a crescent; the colour cools from amber to a pale blue on the way.
 */
export function ThemeSwitch({
  value,
  onValueChange,
  label,
  accessibilityLabel = 'Dark mode',
  haptics = true,
  testID,
  style,
}: ThemeSwitchProps) {
  const { colors, font, radius } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const dark = value === 'dark';
  const p = useSharedValue(dark ? 1 : 0);
  const press = useSharedValue(0);

  useEffect(() => {
    p.set(withSpring(dark ? 1 : 0, spring('smooth', reduced)));
  }, [dark, reduced, p]);

  const ink = useDerivedValue(() =>
    interpolateColor(Math.min(1, Math.max(0, p.get())), [0, 1], [colors.warning, colors.sky]),
  );
  const core = useDerivedValue(() => 5.5 + p.get() * 3.5);
  const biteX = useDerivedValue(() => C + 14 - p.get() * 9.5);
  const biteY = useDerivedValue(() => C - 14 + p.get() * 10);
  const rays = useDerivedValue(() => {
    const t = Math.min(1, Math.max(0, p.get()));
    const b = Skia.PathBuilder.Make();
    const inner = 9;
    const outer = 12.5 - t * 3.5;
    if (outer - inner < 0.2) return b.detach();
    for (let i = 0; i < 8; i++) {
      const a = (i / 8) * Math.PI * 2 + t * (Math.PI / 2);
      b.moveTo(C + Math.cos(a) * inner, C + Math.sin(a) * inner);
      b.lineTo(C + Math.cos(a) * outer, C + Math.sin(a) * outer);
    }
    return b.detach();
  });

  const shell = useAnimatedStyle(() => ({ transform: [{ scale: 1 - press.get() * 0.08 }] }));

  return (
    <Pressable
      testID={testID}
      onPress={() => {
        haptic('tap');
        onValueChange(dark ? 'light' : 'dark');
      }}
      onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
      onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
      accessibilityRole="switch"
      accessibilityLabel={label ?? accessibilityLabel}
      accessibilityState={{ checked: dark }}
      style={[styles.row, style]}
    >
      <Animated.View
        style={[
          styles.box,
          {
            backgroundColor: colors.surfaceRaised,
            borderColor: colors.border,
            borderRadius: radius.pill,
          },
          shell,
        ]}
      >
        <Canvas style={styles.icon}>
          <Group layer>
            <Circle cx={C} cy={C} r={core} color={ink} />
            <Circle cx={biteX} cy={biteY} r={7.5} color={ink} blendMode="dstOut" />
          </Group>
          <Path path={rays} style="stroke" strokeWidth={2} strokeCap="round" color={ink} />
        </Canvas>
      </Animated.View>
      {label ? (
        <View style={styles.text}>
          <Text
            style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.medium }}
          >
            {label}
          </Text>
          <Text style={{ color: colors.textMuted, fontSize: font.size.xs }}>
            {dark ? 'On' : 'Off'}
          </Text>
        </View>
      ) : null}
    </Pressable>
  );
}

const styles = StyleSheet.create({
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    minHeight: BOX,
    alignSelf: 'flex-start',
  },
  box: { width: BOX, height: BOX, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
  icon: { width: ICON, height: ICON },
  text: { gap: 1 },
});