{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-button",
  "type": "registry:item",
  "title": "Status Button",
  "description": "A button that folds into a spinner while it works, then opens in green with a traced check, or shakes in red.",
  "categories": [
    "interaction"
  ],
  "dependencies": [
    "@shopify/react-native-skia",
    "react-native-gesture-handler",
    "react-native-reanimated",
    "react-native-worklets"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/kinetik-motion.json",
    "https://kinetik-ui.dev/r/kinetik-haptics.json",
    "https://kinetik-ui.dev/r/kinetik-clock.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/status-button.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/status-button.tsx",
      "content": "import { Canvas, Group, Path, Skia, vec } from '@shopify/react-native-skia';\nimport { useEffect, useRef, useState } from 'react';\nimport {\n  StyleSheet,\n  Text,\n  View,\n  type AccessibilityActionEvent,\n  type LayoutChangeEvent,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withSequence,\n  withSpring,\n  withTiming,\n} from 'react-native-reanimated';\nimport { scheduleOnRN } from 'react-native-worklets';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { useShaderClock } from '../../lib/kinetik/hooks/use-shader-clock';\nimport { spring, timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\n\nexport type ButtonStatus = 'idle' | 'loading' | 'success' | 'error';\n\nexport type StatusButtonProps = {\n  /** Label while idle. */\n  children: string;\n  /** Drives the button. Set `loading` while work runs, then `success` or `error`. */\n  status: ButtonStatus;\n  onPress?: () => void;\n  /** Label once it worked. Defaults to 'Done'. */\n  successLabel?: string;\n  /** Label when it failed. Defaults to 'Try again'. */\n  errorLabel?: string;\n  /** Haptics on success and error. Defaults to true. */\n  haptics?: boolean;\n  disabled?: boolean;\n  style?: StyleProp<ViewStyle>;\n};\n\nconst HEIGHT = 52;\nconst ICON = 20;\nconst PAD = 26;\n\n// A check and a cross on a 20 pt grid, drawn as paths so they can be traced in.\nconst CHECK = Skia.Path.MakeFromSVGString('M4.5 10.5 L8.5 14.5 L15.5 6')!;\nconst CROSS = Skia.Path.MakeFromSVGString('M5.5 5.5 L14.5 14.5 M14.5 5.5 L5.5 14.5')!;\nconst ARC = Skia.PathBuilder.Make()\n  .addArc({ x: 2, y: 2, width: ICON - 4, height: ICON - 4 }, 0, 270)\n  .detach();\n\n/**\n * A button that shows the work it starts. While loading it folds into a\n * circle with a spinner; on success it opens again in green and traces a\n * check, and on error it turns red, traces a cross and shakes.\n */\nexport function StatusButton({\n  children,\n  status,\n  onPress,\n  successLabel = 'Done',\n  errorLabel = 'Try again',\n  haptics = true,\n  disabled = false,\n  style,\n}: StatusButtonProps) {\n  const { colors, font } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n\n  // Natural width of each label, measured off screen, so the width can spring between them.\n  const [widths, setWidths] = useState({ idle: 0, success: 0, error: 0 });\n  const measure = (key: keyof typeof widths) => (e: LayoutChangeEvent) => {\n    const w = Math.ceil(e.nativeEvent.layout.width);\n    setWidths((s) => (s[key] === w ? s : { ...s, [key]: w }));\n  };\n\n  const width = useSharedValue(0);\n  const success = useSharedValue(0);\n  const error = useSharedValue(0);\n  const loading = useSharedValue(0);\n  const trace = useSharedValue(0);\n  const shake = useSharedValue(0);\n  const pressed = useSharedValue(0);\n  const time = useShaderClock({ running: status === 'loading' && !reduced });\n\n  const target =\n    status === 'loading'\n      ? HEIGHT\n      : status === 'success'\n        ? widths.success\n        : status === 'error'\n          ? widths.error\n          : widths.idle;\n\n  const previous = useRef(status);\n  useEffect(() => {\n    if (target === 0) return;\n    // The first measurement jumps; later changes spring.\n    width.set(width.get() === 0 ? target : withSpring(target, spring('smooth', reduced)));\n  }, [target, reduced, width]);\n\n  useEffect(() => {\n    const was = previous.current;\n    previous.current = status;\n    loading.set(withTiming(status === 'loading' ? 1 : 0, timing('quick', reduced)));\n    success.set(withTiming(status === 'success' ? 1 : 0, timing('base', reduced)));\n    error.set(withTiming(status === 'error' ? 1 : 0, timing('base', reduced)));\n    trace.set(0);\n    if (status === 'success' || status === 'error') {\n      trace.set(withTiming(1, { ...timing('slow', reduced), duration: 420 }));\n    }\n    if (was === status) return;\n    if (status === 'success') haptic('success');\n    if (status === 'error') {\n      haptic('error');\n      if (!reduced) {\n        shake.set(\n          withSequence(\n            withTiming(-8, { duration: 50 }),\n            withTiming(8, { duration: 70 }),\n            withTiming(-5, { duration: 60 }),\n            withTiming(4, { duration: 60 }),\n            withSpring(0, spring('bouncy')),\n          ),\n        );\n      }\n    }\n    // `haptic` is a stable function of the haptics flag.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [status, reduced]);\n\n  const interactive = !disabled && (status === 'idle' || status === 'error');\n  const press = () => onPress?.();\n\n  const tap = Gesture.Tap()\n    .withTestId('status-button')\n    .enabled(interactive)\n    .maxDuration(60_000)\n    .maxDistance(32)\n    .onBegin(() => {\n      pressed.set(withSpring(1, spring('snappy', reduced)));\n    })\n    .onFinalize(() => {\n      pressed.set(withSpring(0, spring('bouncy', reduced)));\n    })\n    .onEnd((_e, ok) => {\n      if (ok) scheduleOnRN(press);\n    });\n\n  const shell = useAnimatedStyle(() => ({\n    width: width.get() || undefined,\n    transform: [{ translateX: shake.get() }, { scale: 1 - pressed.get() * 0.04 }],\n  }));\n  const successFill = useAnimatedStyle(() => ({ opacity: success.get() }));\n  const errorFill = useAnimatedStyle(() => ({ opacity: error.get() }));\n  const idleLabel = useAnimatedStyle(() => {\n    const out = Math.max(loading.get(), success.get(), error.get());\n    return { opacity: 1 - out, transform: [{ scale: 1 - out * 0.15 }] };\n  });\n  const spinner = useAnimatedStyle(() => ({\n    opacity: loading.get(),\n    transform: [{ scale: 0.6 + loading.get() * 0.4 }],\n  }));\n  const successRow = useAnimatedStyle(() => ({\n    opacity: success.get(),\n    transform: [{ translateY: (1 - success.get()) * 8 }],\n  }));\n  const errorRow = useAnimatedStyle(() => ({\n    opacity: error.get(),\n    transform: [{ translateY: (1 - error.get()) * 8 }],\n  }));\n\n  const turn = useDerivedValue(() => [{ rotate: time.get() * Math.PI * 2.4 }]);\n  const traced = useDerivedValue(() => trace.get());\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName === 'activate' && interactive) press();\n  };\n\n  const label = (text: string, color: string) => (\n    <Text\n      numberOfLines={1}\n      style={{ color, fontSize: font.size.md, fontWeight: font.weight.semibold }}\n    >\n      {text}\n    </Text>\n  );\n  const name =\n    status === 'loading'\n      ? `${children}, in progress`\n      : status === 'success'\n        ? successLabel\n        : status === 'error'\n          ? errorLabel\n          : children;\n\n  return (\n    <View style={[styles.slot, style]}>\n      {/* Off-screen copies that measure each label's natural width. */}\n      <View\n        style={styles.measure}\n        pointerEvents=\"none\"\n        importantForAccessibility=\"no-hide-descendants\"\n        accessibilityElementsHidden\n      >\n        <View style={styles.measureRow} onLayout={measure('idle')}>\n          {label(children, colors.onEmber)}\n        </View>\n        <View style={[styles.measureRow, styles.withIcon]} onLayout={measure('success')}>\n          <View style={{ width: ICON }} />\n          {label(successLabel, colors.onEmber)}\n        </View>\n        <View style={[styles.measureRow, styles.withIcon]} onLayout={measure('error')}>\n          <View style={{ width: ICON }} />\n          {label(errorLabel, colors.onEmber)}\n        </View>\n      </View>\n\n      <GestureDetector gesture={tap}>\n        <Animated.View\n          collapsable={false}\n          accessible\n          accessibilityRole=\"button\"\n          accessibilityLabel={name}\n          accessibilityState={{ disabled: disabled || !interactive, busy: status === 'loading' }}\n          accessibilityLiveRegion=\"polite\"\n          accessibilityActions={[{ name: 'activate' }]}\n          onAccessibilityAction={onAccessibilityAction}\n          style={[\n            styles.button,\n            { backgroundColor: colors.ember, opacity: disabled ? 0.45 : 1 },\n            shell,\n          ]}\n        >\n          <Animated.View\n            pointerEvents=\"none\"\n            style={[StyleSheet.absoluteFill, { backgroundColor: colors.success }, successFill]}\n          />\n          <Animated.View\n            pointerEvents=\"none\"\n            style={[StyleSheet.absoluteFill, { backgroundColor: colors.danger }, errorFill]}\n          />\n\n          <Animated.View style={[styles.layer, idleLabel]}>\n            {label(children, colors.onEmber)}\n          </Animated.View>\n\n          <Animated.View style={[styles.layer, spinner]} pointerEvents=\"none\">\n            <View style={styles.icon}>\n              <Canvas style={StyleSheet.absoluteFill}>\n                <Group origin={vec(ICON / 2, ICON / 2)} transform={turn}>\n                  <Path\n                    path={ARC}\n                    style=\"stroke\"\n                    strokeWidth={2.25}\n                    strokeCap=\"round\"\n                    color={colors.onEmber}\n                  />\n                </Group>\n              </Canvas>\n            </View>\n          </Animated.View>\n\n          <Animated.View style={[styles.layer, styles.withIcon, successRow]} pointerEvents=\"none\">\n            <View style={styles.icon}>\n              <Canvas style={StyleSheet.absoluteFill}>\n                <Path\n                  path={CHECK}\n                  style=\"stroke\"\n                  strokeWidth={2.4}\n                  strokeCap=\"round\"\n                  strokeJoin=\"round\"\n                  color={colors.onEmber}\n                  end={traced}\n                />\n              </Canvas>\n            </View>\n            {label(successLabel, colors.onEmber)}\n          </Animated.View>\n\n          <Animated.View style={[styles.layer, styles.withIcon, errorRow]} pointerEvents=\"none\">\n            <View style={styles.icon}>\n              <Canvas style={StyleSheet.absoluteFill}>\n                <Path\n                  path={CROSS}\n                  style=\"stroke\"\n                  strokeWidth={2.4}\n                  strokeCap=\"round\"\n                  color={colors.onEmber}\n                  end={traced}\n                />\n              </Canvas>\n            </View>\n            {label(errorLabel, colors.onEmber)}\n          </Animated.View>\n        </Animated.View>\n      </GestureDetector>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  slot: { height: HEIGHT, alignSelf: 'flex-start', alignItems: 'center' },\n  button: {\n    height: HEIGHT,\n    minWidth: HEIGHT,\n    borderRadius: HEIGHT / 2,\n    overflow: 'hidden',\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  layer: {\n    ...StyleSheet.absoluteFill,\n    flexDirection: 'row',\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  withIcon: { gap: 8 },\n  icon: { width: ICON, height: ICON },\n  // Wide enough that labels never wrap while being measured.\n  measure: { position: 'absolute', opacity: 0, top: 0, left: 0, width: 640 },\n  measureRow: {\n    position: 'absolute',\n    flexDirection: 'row',\n    paddingHorizontal: PAD,\n    alignSelf: 'flex-start',\n  },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "interaction",
    "web": "live",
    "perf": {
      "budget": "Width, colour and icon animations are springs and timings on the UI thread; the spinner's frame callback runs only while loading.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 27,
          "p90Ms": 34,
          "baselineMs": 20,
          "jankyPercent": 7.11,
          "atRest": "idle",
          "memoryMb": 158
        }
      ]
    },
    "a11y": {
      "reducedMotion": "States switch with a short fade: no width spring, spinning or shake.",
      "screenReader": "Button role; its name follows the state (label, 'in progress', success or error label) and is announced politely. Busy while loading.",
      "touchTarget": "52 pt tall; at least 52 pt wide while loading."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { StatusButton, type ButtonStatus } from '@/components/kinetik/status-button';\n\nconst [status, setStatus] = useState<ButtonStatus>('idle');\n\n<StatusButton\n  status={status}\n  successLabel=\"Paid\"\n  onPress={async () => {\n    setStatus('loading');\n    try {\n      await pay();\n      setStatus('success');\n    } catch {\n      setStatus('error');\n    }\n  }}\n>\n  Pay $24.00\n</StatusButton>;\n```\n\nThe button is controlled: you own `status`. Presses are ignored while loading and after success; an error state can be pressed again to retry. Return to `idle` when you are ready, for example a couple of seconds after success.\n\n## When to use\n\n- Actions that take a moment and can fail: paying, sending, saving to a server.\n- Places where the result should show where the person is already looking.\n\n## When not to use\n\n- Instant actions. A spinner that flashes for 50 ms reads as a glitch.\n- Long jobs (uploads, exports). Show real progress instead.\n\n## Notes\n\n- Widths are measured from the labels, so the button springs between their natural sizes.\n- A success or error haptic plays when the state changes.\n- With reduced motion the states cross-fade without the spring, spin or shake.\n"
  },
  "docs": "Prefer `npx kinetik-ui add status-button`: it installs dependencies with expo install, so versions match your Expo SDK."
}
