Theme Switch
A light and dark switch drawn as a sun whose rays turn and draw in while a shadow slides across to leave a moon.
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 },
});Installation
npx kinetik-ui add theme-switchpnpm dlx kinetik-ui add theme-switchbunx kinetik-ui add theme-switchyarn dlx kinetik-ui add theme-switchThe 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
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/theme-switch.tsx
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
labelthe 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
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | ThemeMode | — | |
onValueChangerequired | (value: ThemeMode) => void | — | |
label | string | — | Words beside the icon, e.g. "Dark mode". Leave out for an icon-only button. |
accessibilityLabel | string | 'Dark mode' | Read by screen readers when there is no visible label. Defaults to "Dark mode". |
haptics | boolean | true | A tap as it switches. Defaults to true. |
testID | string | — | |
style | StyleProp<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 },
});