{"slug":"otp-input","name":"OTP Input","description":"Digits roll into slots, with SMS autofill.","group":"Forms","detail_url":"https://beui.dev/native/r/otp-input","page_url":"https://beui.dev/react-native/otp-input","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/otp-input?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/otp-input","libraries":["react","react-native","react-native-reanimated","uniwind"],"install":["npx expo install react-native-reanimated react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/otp-input/index.ts","content":"export type { OTPInputProps, OTPStatus } from \"./otp-input\";\nexport { OTPInput } from \"./otp-input\";\n","type":"component"},{"path":"src/components/otp-input/otp-input.tsx","content":"import { type Ref, useEffect, useRef, useState } from \"react\";\nimport {\n  AccessibilityInfo,\n  Platform,\n  Pressable,\n  StyleSheet,\n  Text,\n  TextInput,\n  type TextInputProps,\n  View,\n} from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withRepeat,\n  withSequence,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames, useUniwind } from \"uniwind\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { useShake } from \"../../hooks/use-shake\";\nimport { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { EASE_OUT, motion } from \"../../styles/motion\";\n\nexport type OTPStatus = \"idle\" | \"error\" | \"success\";\n\nconst SLOT_WIDTH = 48;\nconst SLOT_HEIGHT = 56;\nconst ROLL = 14;\nconst DIGIT_TIMING = { duration: 220, easing: EASE_OUT };\nconst COLOR_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\nconst SHAKE = [0, -5, 5, -3, 3, -1, 0];\nconst CHECK: PathSegment[] = [\n  [5, 13, 9, 17],\n  [9, 17, 19, 7],\n];\nconst CHECK_SPRING = { stiffness: 500, damping: 28 };\n\nexport type OTPInputProps = Omit<\n  TextInputProps,\n  | \"value\"\n  | \"defaultValue\"\n  | \"onChangeText\"\n  | \"maxLength\"\n  | \"editable\"\n  | \"keyboardType\"\n  | \"secureTextEntry\"\n> & {\n  /** Number of slots. Defaults to 6. */\n  length?: number;\n  /** Controlled code. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string;\n  defaultValue?: string;\n  onChangeText?: (value: string) => void;\n  /** Fires once every slot is filled. */\n  onComplete?: (value: string) => void;\n  label?: string;\n  /** Helper text below the slots while idle. */\n  hint?: string;\n  successMessage?: string;\n  errorMessage?: string;\n  /** Validation feedback: \"error\" shakes, \"success\" draws a check. */\n  status?: OTPStatus;\n  /** Shows dots instead of the typed digits. */\n  mask?: boolean;\n  disabled?: boolean;\n  /** Disables digit, caret and feedback motion independently of the system preference. */\n  disableAnimation?: boolean;\n  /** Dismisses the keyboard once the last slot fills. */\n  blurOnComplete?: boolean;\n  className?: string;\n  ref?: Ref<TextInput>;\n};\n\nfunction sanitize(raw: string, length: number) {\n  return raw.replace(/\\D/g, \"\").slice(0, length);\n}\n\n/** Progress runs 0 (below) → 1 (resting) → 2 (rolled out above). */\nfunction Digit({\n  char,\n  appear,\n  exiting,\n  reduce,\n}: {\n  char: string;\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n}) {\n  const progress = useSharedValue(appear && !reduce ? 0 : 1);\n\n  useEffect(() => {\n    const target = exiting ? 2 : 1;\n    progress.value = reduce ? target : withTiming(target, DIGIT_TIMING);\n  }, [exiting, progress, reduce]);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: p <= 1 ? p : 2 - p,\n      transform: [{ translateY: (1 - p) * ROLL }],\n    };\n  });\n\n  return (\n    <Animated.View style={[styles.digit, animatedStyle]}>\n      <Text\n        maxFontSizeMultiplier={1.4}\n        className=\"text-xl font-semibold text-foreground\"\n        style={styles.tabular}\n      >\n        {char}\n      </Text>\n    </Animated.View>\n  );\n}\n\nfunction Caret({ filled, reduce }: { filled: boolean; reduce: boolean }) {\n  const opacity = useSharedValue(1);\n\n  useEffect(() => {\n    if (reduce) return;\n    // On for half a second, off for half, like the web caret.\n    opacity.value = withRepeat(\n      withSequence(\n        withDelay(500, withTiming(0, { duration: 0 })),\n        withDelay(500, withTiming(1, { duration: 0 })),\n      ),\n      -1,\n    );\n    return () => cancelAnimation(opacity);\n  }, [opacity, reduce]);\n\n  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));\n\n  return (\n    <Animated.View\n      className=\"bg-foreground\"\n      style={[styles.caret, filled ? styles.caretEnd : null, animatedStyle]}\n    />\n  );\n}\n\nfunction Slot({\n  char,\n  active,\n  status,\n  mask,\n  reduce,\n}: {\n  char: string;\n  active: boolean;\n  status: OTPStatus;\n  mask: boolean;\n  reduce: boolean;\n}) {\n  const { backgroundColor: idle } = useResolveClassNames(\"bg-border\");\n  const { backgroundColor: filled } = useResolveClassNames(\"bg-border-strong\");\n  const { backgroundColor: focus } = useResolveClassNames(\"bg-foreground\");\n  const { backgroundColor: error } = useResolveClassNames(\"bg-destructive/60\");\n  const { backgroundColor: success } = useResolveClassNames(\"bg-success/60\");\n\n  const color =\n    status === \"success\"\n      ? success\n      : status === \"error\"\n        ? error\n        : active\n          ? focus\n          : char\n            ? filled\n            : idle;\n  const border = useSharedValue(String(color ?? \"transparent\"));\n\n  useEffect(() => {\n    const next = String(color ?? \"transparent\");\n    border.value = reduce ? next : withTiming(next, COLOR_FADE);\n  }, [border, color, reduce]);\n\n  const digits = usePresence(\n    char ? char : null,\n    char,\n    () => DIGIT_TIMING.duration + 40,\n  );\n  const borderStyle = useAnimatedStyle(() => ({ borderColor: border.value }));\n\n  return (\n    <Animated.View style={[styles.slot, borderStyle]}>\n      {active && status !== \"success\" ? (\n        <Caret filled={char !== \"\"} reduce={reduce} />\n      ) : null}\n      {digits.map((item) => (\n        <Digit\n          key={item.key}\n          char={mask ? \"•\" : item.value}\n          appear={item.appear}\n          exiting={item.exiting}\n          reduce={reduce}\n        />\n      ))}\n    </Animated.View>\n  );\n}\n\nfunction SuccessCheck({\n  appear,\n  exiting,\n  reduce,\n}: {\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n}) {\n  const { color } = useResolveClassNames(\"text-success\");\n  const presence = useSharedValue(appear && !reduce ? 0 : 1);\n  const draw = useSharedValue(appear && !reduce ? 0 : 1);\n\n  useEffect(() => {\n    const target = exiting ? 0 : 1;\n    if (reduce) {\n      presence.value = target;\n      draw.value = 1;\n      return;\n    }\n    presence.value = withSpring(target, CHECK_SPRING);\n    if (!exiting) {\n      draw.value = withDelay(\n        100,\n        withTiming(1, { duration: 350, easing: EASE_OUT }),\n      );\n    }\n  }, [draw, exiting, presence, reduce]);\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: Math.min(1, presence.value),\n    transform: [{ scale: 0.6 + 0.4 * presence.value }],\n  }));\n\n  return (\n    <Animated.View style={[styles.check, animatedStyle]}>\n      <DrawnPath\n        segments={CHECK}\n        size={20}\n        strokeWidth={3}\n        color={color}\n        progress={draw}\n      />\n    </Animated.View>\n  );\n}\n\nexport function OTPInput({\n  length = 6,\n  value: valueProp,\n  defaultValue = \"\",\n  onChangeText,\n  onComplete,\n  label,\n  hint,\n  successMessage,\n  errorMessage,\n  status = \"idle\",\n  mask = false,\n  disabled = false,\n  disableAnimation = false,\n  className = \"\",\n  accessibilityLabel,\n  onFocus,\n  onBlur,\n  blurOnComplete = false,\n  keyboardAppearance,\n  ref,\n  ...props\n}: OTPInputProps) {\n  const { theme } = useUniwind();\n  const inputRef = useRef<TextInput | null>(null);\n  // Blur once the filled value and caret have rendered; applying them can\n  // otherwise take focus straight back.\n  const pendingBlur = useRef(false);\n  const [uncontrolled, setUncontrolled] = useState(() =>\n    sanitize(defaultValue, length),\n  );\n  const code = sanitize(valueProp ?? uncontrolled, length);\n  const [focused, setFocused] = useState(false);\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const { shake, style: shakeStyle } = useShake(SHAKE, 450, EASE_OUT);\n  const previousStatus = useRef(status);\n\n  useEffect(() => {\n    if (!pendingBlur.current || code.length < length) return;\n    pendingBlur.current = false;\n    inputRef.current?.blur();\n  }, [code, length]);\n\n  const active = Math.min(code.length, length - 1);\n  const message =\n    status === \"success\"\n      ? successMessage\n      : status === \"error\"\n        ? errorMessage\n        : hint;\n\n  useEffect(() => {\n    if (previousStatus.current === status) return;\n    previousStatus.current = status;\n    if (status === \"error\" && animate) shake();\n    if (status !== \"idle\" && message) {\n      AccessibilityInfo.announceForAccessibility(message);\n    }\n  }, [animate, message, shake, status]);\n\n  const checks = usePresence(\n    status === \"success\" ? \"success\" : null,\n    true,\n    () => 300,\n  );\n\n  return (\n    <View className={`self-start gap-2 ${className}`}>\n      {label ? (\n        <Text\n          accessibilityElementsHidden\n          importantForAccessibility=\"no\"\n          className=\"text-sm font-medium text-foreground\"\n        >\n          {label}\n        </Text>\n      ) : null}\n\n      <Pressable\n        // Focus on any tap across the slots, even one the field misses.\n        accessible={false}\n        disabled={disabled}\n        onPress={() => inputRef.current?.focus()}\n        style={disabled && styles.disabled}\n      >\n        <Animated.View\n          className=\"flex-row items-center gap-2\"\n          style={shakeStyle}\n          accessibilityElementsHidden\n          importantForAccessibility=\"no-hide-descendants\"\n        >\n          {Array.from({ length }, (_, index) => (\n            <Slot\n              // Fixed-length slot grid that never reorders.\n              key={index}\n              char={code[index] ?? \"\"}\n              active={focused && index === active}\n              status={status}\n              mask={mask}\n              reduce={!animate}\n            />\n          ))}\n        </Animated.View>\n\n        {checks.map((item) => (\n          <SuccessCheck\n            key={item.key}\n            appear={item.appear}\n            exiting={item.exiting}\n            reduce={!animate}\n          />\n        ))}\n\n        {/* A transparent field over the slots owns focus, the keyboard, paste\n            and SMS autofill. The slots only draw what it holds. */}\n        <TextInput\n          {...props}\n          ref={(node) => {\n            inputRef.current = node;\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) ref.current = node;\n          }}\n          // Match the keyboard to the app's theme unless the caller chooses one.\n          keyboardAppearance={keyboardAppearance ?? theme}\n          value={code}\n          editable={!disabled}\n          maxLength={length}\n          keyboardType=\"number-pad\"\n          inputMode=\"numeric\"\n          textContentType=\"oneTimeCode\"\n          autoComplete={Platform.OS === \"android\" ? \"sms-otp\" : \"one-time-code\"}\n          caretHidden\n          // Nothing of the native field shows; the slots draw everything.\n          selectionColor=\"transparent\"\n          underlineColorAndroid=\"transparent\"\n          contextMenuHidden={false}\n          // Keep the native caret at the end so edits always append or delete last.\n          selection={{ start: code.length, end: code.length }}\n          accessibilityLabel={\n            accessibilityLabel ?? label ?? \"One-time passcode\"\n          }\n          accessibilityHint={message}\n          aria-invalid={status === \"error\"}\n          aria-disabled={disabled}\n          style={styles.capture}\n          onChangeText={(raw) => {\n            const next = sanitize(raw, length);\n            if (next === code) return;\n            const completed = code.length < length && next.length === length;\n            // Set before notifying: a parent update can render synchronously.\n            if (completed && blurOnComplete) pendingBlur.current = true;\n            if (valueProp === undefined) setUncontrolled(next);\n            onChangeText?.(next);\n            if (completed) onComplete?.(next);\n          }}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n        />\n      </Pressable>\n\n      {message ? (\n        <Text\n          accessibilityLiveRegion=\"polite\"\n          className={`text-sm ${status === \"success\" ? \"text-success\" : status === \"error\" ? \"text-destructive\" : \"text-muted\"}`}\n        >\n          {message}\n        </Text>\n      ) : null}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  disabled: { opacity: 0.5 },\n  slot: {\n    width: SLOT_WIDTH,\n    height: SLOT_HEIGHT,\n    borderRadius: 12,\n    borderWidth: 1,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    overflow: \"hidden\",\n  },\n  digit: {\n    ...StyleSheet.absoluteFill,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  tabular: { fontVariant: [\"tabular-nums\"] },\n  caret: { position: \"absolute\", width: 1, height: 24 },\n  caretEnd: { right: 12 },\n  check: {\n    position: \"absolute\",\n    right: -28,\n    top: (SLOT_HEIGHT - 20) / 2,\n    pointerEvents: \"none\",\n  },\n  capture: {\n    ...StyleSheet.absoluteFill,\n    // iOS skips views below 0.01 opacity when hit-testing, so a fully\n    // transparent field never receives the tap that opens the keyboard.\n    opacity: 0.011,\n    color: \"transparent\",\n    backgroundColor: \"transparent\",\n  },\n});\n","type":"component"},{"path":"src/hooks/use-presence.ts","content":"import { useEffect, useRef, useState } from \"react\";\n\nexport type PresenceItem<T> = {\n  key: string;\n  value: T;\n  /** False for the item present on first render, so it does not animate in. */\n  appear: boolean;\n  exiting: boolean;\n};\n\n/**\n * Keeps replaced items mounted while they animate out, like AnimatePresence.\n * A key that returns while exiting is revived in place.\n */\nexport function usePresence<T>(\n  key: string | null,\n  value: T,\n  exitDuration: (value: T) => number,\n) {\n  const [items, setItems] = useState<PresenceItem<T>[]>(() =>\n    key === null ? [] : [{ key, value, appear: false, exiting: false }],\n  );\n  const [currentKey, setCurrentKey] = useState(key);\n  const exitDurationRef = useRef(exitDuration);\n  exitDurationRef.current = exitDuration;\n\n  // Adjust during render so the new item and the exits start in the same frame.\n  if (key !== currentKey) {\n    setCurrentKey(key);\n    setItems((previous) => {\n      const next = previous.map((item) =>\n        item.key === key\n          ? { ...item, value, exiting: false }\n          : { ...item, exiting: true },\n      );\n      if (key !== null && !previous.some((item) => item.key === key)) {\n        next.push({ key, value, appear: true, exiting: false });\n      }\n      return next;\n    });\n  }\n\n  const exitingKeys = items\n    .filter((item) => item.exiting)\n    .map((item) => item.key)\n    .join(\"\\n\");\n\n  useEffect(() => {\n    if (!exitingKeys) return;\n    const timers = items\n      .filter((item) => item.exiting)\n      .map((item) =>\n        setTimeout(() => {\n          setItems((previous) =>\n            previous.filter(\n              (candidate) => !(candidate.key === item.key && candidate.exiting),\n            ),\n          );\n        }, exitDurationRef.current(item.value)),\n      );\n    return () => {\n      for (const timer of timers) clearTimeout(timer);\n    };\n    // Restart removal timers only when the set of exiting items changes.\n  }, [exitingKeys]);\n\n  return items;\n}\n","type":"shared"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/hooks/use-shake.ts","content":"import { useCallback } from \"react\";\nimport {\n  type EasingFunction,\n  type EasingFunctionFactory,\n  useAnimatedStyle,\n  useSharedValue,\n  withSequence,\n  withTiming,\n} from \"react-native-reanimated\";\n\n/**\n * Horizontal shake through `keyframes` (in points) over `duration` ms, split\n * evenly like a Motion keyframe array. Callers skip it under reduced motion.\n */\nexport function useShake(\n  keyframes: number[],\n  duration: number,\n  easing?: EasingFunction | EasingFunctionFactory,\n) {\n  const offset = useSharedValue(0);\n  const step = duration / Math.max(1, keyframes.length - 1);\n\n  const shake = useCallback(() => {\n    const [first = 0, ...rest] = keyframes;\n    offset.value = first;\n    offset.value = withSequence(\n      // An explicit `easing: undefined` would replace Reanimated's default.\n      ...rest.map((point) =>\n        withTiming(\n          point,\n          easing ? { duration: step, easing } : { duration: step },\n        ),\n      ),\n    );\n  }, [easing, keyframes, offset, step]);\n\n  const style = useAnimatedStyle(() => ({\n    transform: [{ translateX: offset.value }],\n  }));\n\n  return { shake, style };\n}\n","type":"shared"},{"path":"src/primitives/drawn-path.tsx","content":"import { type ColorValue, StyleSheet, View } from \"react-native\";\nimport Animated, {\n  interpolate,\n  type SharedValue,\n  useAnimatedStyle,\n} from \"react-native-reanimated\";\n\n/** A straight line in a square viewBox: [x1, y1, x2, y2]. */\nexport type PathSegment = [x1: number, y1: number, x2: number, y2: number];\n\nexport type DrawnPathProps = {\n  /** Straight segments drawn in order, like an SVG polyline. */\n  segments: PathSegment[];\n  /** Rendered width and height in points. */\n  size: number;\n  /** Stroke width in viewBox units. */\n  strokeWidth: number;\n  /** Side of the square viewBox the segments are written in. Defaults to 24. */\n  viewBox?: number;\n  color: ColorValue | undefined;\n  /** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */\n  progress: SharedValue<number>;\n};\n\nfunction length([x1, y1, x2, y2]: PathSegment) {\n  return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/**\n * Draws a polyline on with rotated Views, so shared components need no SVG\n * library. Round caps extend half a stroke past each end, like SVG's linecap.\n */\nexport function DrawnPath({\n  segments,\n  size,\n  strokeWidth,\n  viewBox = 24,\n  color,\n  progress,\n}: DrawnPathProps) {\n  const unit = size / viewBox;\n  const stroke = strokeWidth * unit;\n  const total = segments.reduce((sum, segment) => sum + length(segment), 0);\n  let start = 0;\n\n  return (\n    <View style={{ width: size, height: size }}>\n      {segments.map((segment) => {\n        const from = start / total;\n        start += length(segment);\n        return (\n          <Stroke\n            key={segment.join()}\n            segment={segment}\n            unit={unit}\n            stroke={stroke}\n            from={from}\n            to={start / total}\n            progress={progress}\n            color={color}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nfunction Stroke({\n  segment,\n  unit,\n  stroke,\n  from,\n  to,\n  progress,\n  color,\n}: {\n  segment: PathSegment;\n  unit: number;\n  stroke: number;\n  from: number;\n  to: number;\n  progress: SharedValue<number>;\n  color: ColorValue | undefined;\n}) {\n  const [x1, y1, x2, y2] = segment;\n  const full = length(segment) * unit;\n  const angle = Math.atan2(y2 - y1, x2 - x1);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const drawn = interpolate(progress.value, [from, to], [0, 1], \"clamp\");\n    return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };\n  });\n\n  return (\n    <Animated.View\n      style={[\n        styles.stroke,\n        {\n          left: x1 * unit - stroke / 2,\n          top: y1 * unit - stroke / 2,\n          height: stroke,\n          borderRadius: stroke / 2,\n          backgroundColor: color,\n          // Rotate around the start point so the stroke grows from it.\n          transformOrigin: [stroke / 2, stroke / 2, 0],\n          transform: [{ rotate: `${angle}rad` }],\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  stroke: { position: \"absolute\" },\n});\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n}\n","type":"theme"}]}