Disclosure
Switch Disclosure
A spring toggle reveals a checkbox with a blurred checkmark
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
switch-disclosure.tsx
import React, {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react"
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
useColorScheme,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
ReduceMotion,
defineAnimation,
interpolateColor,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
type AnimationCallback,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Defs, FeGaussianBlur, Filter, G, Path } from "react-native-svg"
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const SPRING = {
stiffness: 800,
damping: 80,
mass: 5,
reduceMotion: ReduceMotion.System,
} as const
const COLOR = {
duration: 300,
easing: Easing.bezier(0.4, 0, 0.2, 1),
reduceMotion: ReduceMotion.System,
} as const
const HOVER = { ...COLOR, duration: 150 }
type SpringState = {
current: number
toValue: number
velocity: number
origin: number
started: number
lastTimestamp: number
initialVelocity: number
factor: number
restSpeed: number
restDelta: number
callback?: AnimationCallback
onStart: (
a: SpringState,
value: number,
now: number,
previous?: SpringState
) => void
onFrame: (a: SpringState, now: number) => boolean
}
function motionSpring(
target: number,
range?: number,
callback?: AnimationCallback
): number {
"worklet"
return defineAnimation<SpringState>(target, () => {
"worklet"
return {
current: target,
toValue: target,
velocity: 0,
origin: target,
started: 0,
lastTimestamp: 0,
initialVelocity: 0,
factor: 1,
restSpeed: 2,
restDelta: 0.5,
callback,
onStart(a, value, now, previous) {
a.current = a.origin = value
a.started = a.lastTimestamp = now
a.initialVelocity = previous?.lastTimestamp ? previous.velocity : 0
a.velocity = a.initialVelocity
const delta = Math.abs(value - a.toValue)
a.factor = range && delta ? range / delta : 1
const granular = delta * a.factor < 5
a.restSpeed = granular ? 0.01 : 2
a.restDelta = granular ? 0.005 : 0.5
},
onFrame(a, now) {
const t = (now - a.started) / 1000
const decay = SPRING.damping / (2 * SPRING.mass)
const angular = Math.sqrt(
SPRING.stiffness / SPRING.mass - decay * decay
)
const x = a.origin - a.toValue
const b = (a.initialVelocity + decay * x) / angular
const envelope = Math.exp(-decay * t)
const cosine = Math.cos(angular * t),
sine = Math.sin(angular * t)
a.current = a.toValue + envelope * (x * cosine + b * sine)
a.velocity =
envelope *
((b * angular - decay * x) * cosine -
(x * angular + decay * b) * sine)
a.lastTimestamp = now
if (
Math.abs(a.velocity) * a.factor <= a.restSpeed &&
Math.abs(a.current - a.toValue) * a.factor <= a.restDelta
) {
a.current = a.toValue
a.velocity = a.lastTimestamp = 0
return true
}
return false
},
}
}) as unknown as number
}
const BUBBLES =
"M16 16C17.6569 16 19 17.3431 19 19C19 20.6569 17.6569 22 16 22C14.3431 22 13 20.6569 13 19C13 17.3431 14.3431 16 16 16ZM6 12C8.20914 12 10 13.7909 10 16C10 18.2091 8.20914 20 6 20C3.79086 20 2 18.2091 2 16C2 13.7909 3.79086 12 6 12ZM14.5 2C17.5376 2 20 4.46243 20 7.5C20 10.5376 17.5376 13 14.5 13C11.4624 13 9 10.5376 9 7.5C9 4.46243 11.4624 2 14.5 2Z"
export type SwitchDisclosureColors = {
surface: string
border: string
header: string
title: string
icon: string
track: string
activeTrack: string
knob: string
option: string
optionHover: string
checkSurface: string
checkBorder: string
checkHoverBorder: string
checkedSurface: string
checkmark: string
}
const DARK: SwitchDisclosureColors = {
surface: "#171717",
border: "#262626",
header: "#262626",
title: "#f5f5f5",
icon: "#737373",
track: "#404040",
activeTrack: "#EE2563",
knob: "#ffffff",
option: "#a3a3a3",
optionHover: "#e5e5e5",
checkSurface: "#0a0a0a",
checkBorder: "#262626",
checkHoverBorder: "#404040",
checkedSurface: "#f5f5f5",
checkmark: "#171717",
}
const LIGHT: SwitchDisclosureColors = {
surface: "transparent",
border: "#EBEBF0",
header: "#F6F5FA",
title: "#28272A",
icon: "#ADACB8",
track: "#e5e7eb",
activeTrack: "#EE2563",
knob: "#ffffff",
option: "#6C6B72",
optionHover: "#1f2937",
checkSurface: "#ffffff",
checkBorder: "#EBEBF0",
checkHoverBorder: "#d1d5db",
checkedSurface: "#28272A",
checkmark: "#ffffff",
}
export type SwitchDisclosureProps = {
title?: string
subOptionLabel?: string
defaultEnabled?: boolean
defaultSubOptionChecked?: boolean
onToggleChange?: (enabled: boolean) => void
onSubOptionChange?: (checked: boolean) => void
theme?: "light" | "dark" | "system"
colors?: Partial<SwitchDisclosureColors>
width?: number
radius?: number
headerRadius?: number
checkRadius?: number
style?: StyleProp<ViewStyle>
testID?: string
}
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (WEB) {
const amount = props.style?.stdDeviation ?? props.stdDeviation
const element = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (key: string, value: string) => void
} | null
}
}
).elementRef?.current
if (amount !== undefined)
element?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AnimatedBlur = Animated.createAnimatedComponent(BlurTarget)
const AnimatedG = Animated.createAnimatedComponent(G)
function Checkmark({
checked,
color,
remove,
press,
}: {
checked: boolean
color: string
remove: () => void
press: SharedValue<number>
}) {
const id = useId().replace(/:/g, "")
const checkedRef = useRef(checked)
checkedRef.current = checked
const opacity = useSharedValue(0)
const scale = useSharedValue(1.2)
const blur = useSharedValue(4)
const removeRef = useRef(remove)
removeRef.current = remove
const finish = () => {
if (!checkedRef.current) removeRef.current()
}
useEffect(() => {
opacity.set(motionSpring(checked ? 1 : 0, 100))
scale.set(motionSpring(checked ? 1 : 0.5))
blur.set(
motionSpring(checked ? 0 : 4, undefined, (finished) => {
"worklet"
if (finished && !checked) scheduleOnRN(finish)
})
)
}, [checked, opacity, scale, blur])
const group = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, opacity.get())),
transform: `translate(19 19) scale(${scale.get() * press.get()}) translate(-7 -7)`,
}))
const blurProps = useAnimatedProps(() => ({
stdDeviation: Math.max(0, blur.get()),
}))
return (
<View pointerEvents="none" accessible={false} style={styles.checkIcon}>
<Svg width={38} height={38} viewBox="0 0 38 38">
<Defs>
<Filter id={id} x="-100%" y="-100%" width="300%" height="300%">
<AnimatedBlur animatedProps={blurProps} stdDeviation={4} />
</Filter>
</Defs>
<AnimatedG animatedProps={group}>
<G filter={`url(#${id})`}>
<Path
d="M20 6 9 17l-5-5"
transform="scale(0.5833333333)"
fill="none"
stroke={color}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
/>
</G>
</AnimatedG>
</Svg>
</View>
)
}
function SubOption({
checked,
label,
colors: c,
radius,
enter,
onPress,
testID,
}: {
checked: boolean
label: string
colors: SwitchDisclosureColors
radius: number
enter: boolean
onPress: () => void
testID?: string
}) {
const scale = useSharedValue(enter ? 1.1 : 1)
const y = useSharedValue(enter ? 10 : 0)
const opacity = useSharedValue(enter ? 0 : 1)
const hover = useSharedValue(0)
const selection = useSharedValue(checked ? 1 : 0)
const press = useSharedValue(1)
const [showCheck, setShowCheck] = useState(checked)
useEffect(() => {
scale.set(motionSpring(1))
y.set(motionSpring(0))
opacity.set(motionSpring(1, 100))
}, [scale, y, opacity])
useEffect(() => {
selection.set(withTiming(checked ? 1 : 0, HOVER))
if (checked) setShowCheck(true)
}, [checked, selection])
const animatedRow = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, opacity.get())),
transform: [{ translateY: y.get() }, { scale: scale.get() }],
}))
const box = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
selection.get(),
[0, 1],
[c.checkSurface, c.checkedSurface]
),
borderColor: interpolateColor(
selection.get(),
[0, 1],
[
interpolateColor(
hover.get(),
[0, 1],
[c.checkBorder, c.checkHoverBorder]
),
c.checkedSurface,
]
),
}))
const text = useAnimatedStyle(() => ({
color: interpolateColor(hover.get(), [0, 1], [c.option, c.optionHover]),
}))
return (
<Animated.View style={[styles.option, animatedRow]}>
<Pressable
testID={testID}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityState={{ checked }}
aria-checked={checked}
onPress={onPress}
onHoverIn={() => hover.set(withTiming(1, HOVER))}
onHoverOut={() => hover.set(withTiming(0, HOVER))}
onPressIn={() => {
if (!WEB) hover.set(withTiming(1, HOVER))
}}
onPressOut={() => {
if (!WEB) hover.set(withTiming(0, HOVER))
}}
style={styles.optionButton}
>
<Animated.View
style={[styles.checkBox, { borderRadius: radius }, box]}
onPointerDown={
WEB
? () => {
if (checked) press.set(motionSpring(1.01))
}
: undefined
}
onPointerUp={WEB ? () => press.set(motionSpring(1)) : undefined}
onPointerCancel={WEB ? () => press.set(motionSpring(1)) : undefined}
onTouchStart={
WEB
? undefined
: () => {
if (checked) press.set(motionSpring(1.01))
}
}
onTouchEnd={WEB ? undefined : () => press.set(motionSpring(1))}
onTouchCancel={WEB ? undefined : () => press.set(motionSpring(1))}
>
{showCheck && (
<Checkmark
checked={checked}
color={c.checkmark}
press={press}
remove={() => setShowCheck(false)}
/>
)}
</Animated.View>
<Animated.Text style={[styles.optionLabel, text]}>
{label}
</Animated.Text>
</Pressable>
</Animated.View>
)
}
export function SwitchDisclosure({
title = "Predictive Completion",
subOptionLabel = "Enable Inline Suggestions",
defaultEnabled = false,
defaultSubOptionChecked = false,
onToggleChange,
onSubOptionChange,
theme = "system",
colors,
width = 340,
radius = 32,
headerRadius = 35,
checkRadius = 8,
style,
testID,
}: SwitchDisclosureProps) {
const scheme = useColorScheme()
const c = {
...((theme === "system" ? scheme === "dark" : theme === "dark")
? DARK
: LIGHT),
...colors,
}
const [enabled, setEnabled] = useState(defaultEnabled)
const [checked, setChecked] = useState(defaultSubOptionChecked)
const enabledRef = useRef(defaultEnabled)
const checkedRef = useRef(defaultSubOptionChecked)
const initial = useRef(true)
const surfaceRef = useRef<View>(null)
const [edge, setEdge] = useState(defaultEnabled ? 1.5 : 1)
const [headerHeight, setHeaderHeight] = useState(64)
const [optionHeight, setOptionHeight] = useState(57.5)
const height = useSharedValue(defaultEnabled ? 134.5 : 76)
const knob = useSharedValue(defaultEnabled ? 20 : 0)
const skin = useSharedValue(defaultEnabled ? 1 : 0)
useLayoutEffect(() => {
const border =
WEB && surfaceRef.current
? parseFloat(
window.getComputedStyle(surfaceRef.current as unknown as Element)
.borderTopWidth
)
: enabled
? 1.5
: 1
setEdge(border)
const target =
headerHeight + (5 + border) * 2 + (enabled ? optionHeight : 0)
if (initial.current) height.set(target)
else height.set(motionSpring(target, 1000))
knob.set(motionSpring(enabled ? 20 : 0))
skin.set(withTiming(enabled ? 1 : 0, COLOR))
initial.current = false
}, [enabled, headerHeight, optionHeight, height, knob, skin])
const root = useAnimatedStyle(() => ({ height: height.get() }))
const surface = useAnimatedStyle(() => ({ opacity: skin.get() }))
const track = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
skin.get(),
[0, 1],
[c.track, c.activeTrack]
),
}))
const thumb = useAnimatedStyle(() => ({
transform: [{ translateX: knob.get() }],
}))
const toggle = () => {
const next = !enabledRef.current
enabledRef.current = next
setEnabled(next)
onToggleChange?.(next)
}
const toggleOption = () => {
const next = !checkedRef.current
checkedRef.current = next
setChecked(next)
onSubOptionChange?.(next)
}
const inset = 5 + edge
return (
<Animated.View
testID={testID}
style={[
{
width,
maxWidth: "100%",
alignSelf: "center",
borderRadius: radius,
overflow: "hidden",
},
style,
root,
]}
>
<Animated.View
ref={surfaceRef}
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{
borderRadius: radius,
borderWidth: enabled ? 1.5 : 1,
borderColor: c.border,
backgroundColor: c.surface,
},
surface,
]}
/>
<View
style={{ position: "absolute", top: inset, left: inset, right: inset }}
>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
accessibilityRole="switch"
accessibilityLabel={title}
accessibilityState={{ checked: enabled }}
aria-checked={enabled}
onPress={toggle}
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
style={[
styles.header,
{ backgroundColor: c.header, borderRadius: headerRadius },
]}
>
<View style={styles.heading}>
<View style={styles.bubbles}>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Path d={BUBBLES} fill={c.icon} />
</Svg>
</View>
<Text style={[styles.title, { color: c.title }]}>{title}</Text>
</View>
<Animated.View pointerEvents="none" style={[styles.track, track]}>
<Animated.View
style={[styles.knob, { backgroundColor: c.knob }, thumb]}
/>
</Animated.View>
</Pressable>
{enabled && (
<View
onLayout={(e) => {
const h = e.nativeEvent.layout.height
setOptionHeight((old) => (Math.abs(old - h) < 1 ? old : h))
}}
>
<SubOption
checked={checked}
label={subOptionLabel}
colors={c}
radius={checkRadius}
enter={!initial.current}
onPress={toggleOption}
testID={testID ? `${testID}-option` : undefined}
/>
</View>
)}
</View>
</Animated.View>
)
}
const shadow =
WEB ||
(Platform.OS === "android" && Number(Platform.Version) >= 28) ||
Platform.OS === "ios"
? { boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)" }
: { elevation: 1 }
const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 12,
...shadow,
},
heading: {
flexDirection: "row",
alignItems: "center",
gap: 12,
flexShrink: 1,
},
bubbles: { padding: 8 },
title: {
fontFamily: FONT,
fontSize: 18,
fontWeight: "700",
letterSpacing: -0.45,
lineHeight: 28,
flexShrink: 1,
includeFontPadding: false,
},
track: { width: 48, height: 28, borderRadius: 999, flexShrink: 0 },
knob: {
position: "absolute",
top: 4,
left: 4,
width: 20,
height: 20,
borderRadius: 999,
...shadow,
},
option: { alignSelf: "flex-start" },
optionButton: {
flexDirection: "row",
alignItems: "center",
gap: 12,
padding: 16,
},
optionLabel: {
fontFamily: FONT,
fontSize: 17,
fontWeight: "600",
lineHeight: 25.5,
includeFontPadding: false,
flexShrink: 1,
},
checkBox: {
width: 24,
height: 24,
borderWidth: 2,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
checkIcon: { position: "absolute", width: 38, height: 38, left: -9, top: -9 },
})
Give the gestures and motion a run on a real Android phone.