Skip to content
pixelfor Expo
Ctrl K

Disclosure

Switch Disclosure

A spring toggle reveals a checkbox with a blurred checkmark

Try it on your phone

Switch Disclosure

Scan to open Switch Disclosure in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI