{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "liquid-tabs",
  "type": "registry:item",
  "title": "Liquid Tabs",
  "description": "A tab bar whose indicator flows like liquid, stretching, thinning and melting into place.",
  "categories": [
    "interaction"
  ],
  "dependencies": [
    "@shopify/react-native-skia",
    "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"
  ],
  "files": [
    {
      "path": "src/components/kinetik/liquid-tabs.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/liquid-tabs.tsx",
      "content": "import {\n  Blur,\n  Canvas,\n  Circle,\n  ColorMatrix,\n  Group,\n  Paint,\n  RoundedRect,\n  rect,\n  rrect,\n} from '@shopify/react-native-skia';\nimport { useEffect, useState } from 'react';\nimport {\n  Platform,\n  Pressable,\n  StyleSheet,\n  View,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport Animated, {\n  interpolateColor,\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withDelay,\n  withSpring,\n  type SharedValue,\n} from 'react-native-reanimated';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { spring } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { minTouchTarget } from '../../lib/kinetik/tokens/tokens';\n\nexport type LiquidTab = { key: string; label: string };\n\nexport type LiquidTabsProps = {\n  tabs: readonly LiquidTab[];\n  /** Key of the selected tab. */\n  value: string;\n  onChange: (key: string) => void;\n  /** Indicator colour. Defaults to the theme ember. */\n  color?: string;\n  /** Label colour on the indicator. Defaults to the theme onEmber. */\n  activeTextColor?: string;\n  /** A tick on change. Defaults to true. */\n  haptics?: boolean;\n  /** Accessible name of the tab list. */\n  accessibilityLabel?: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n// Each tab keeps a full 44 pt target inside the bar's padding.\nconst PAD = 4;\nconst HEIGHT = minTouchTarget + PAD * 2;\n\n// Alpha threshold after a blur: shapes that overlap melt into one (\"goo\").\nconst GOO = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 20, -8];\n\n/**\n * A segmented tab bar whose indicator flows like liquid: the leading edge\n * races ahead, the trailing edge catches up, the body thins as it stretches\n * and a droplet melts back into it on arrival.\n */\nexport function LiquidTabs({\n  tabs,\n  value,\n  onChange,\n  color,\n  activeTextColor,\n  haptics = true,\n  accessibilityLabel,\n  style,\n}: LiquidTabsProps) {\n  const theme = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const goo = GOO_SUPPORTED && !reduced;\n  const haptic = useHaptic(haptics);\n  const fill = color ?? theme.colors.ember;\n  const onFill = activeTextColor ?? theme.colors.onEmber;\n  const [width, setWidth] = useState(0);\n  const count = Math.max(1, tabs.length);\n  const index = Math.max(\n    0,\n    tabs.findIndex((t) => t.key === value),\n  );\n  const segment = width > 0 ? (width - PAD * 2) / count : 0;\n\n  const left = useSharedValue(index);\n  const right = useSharedValue(index + 1);\n  const drop = useSharedValue(index + 0.5);\n\n  useEffect(() => {\n    // Moving right, the right edge leads; moving left, the left edge leads.\n    const forward = index >= left.get();\n    const lead = spring('snappy', reduced);\n    const trail = spring('smooth', reduced);\n    left.set(withSpring(index, forward ? trail : lead));\n    right.set(withSpring(index + 1, forward ? lead : trail));\n    // The droplet leaves a beat late, so it trails the body and melts back in.\n    drop.set(withDelay(reduced ? 0 : 70, withSpring(index + 0.5, trail)));\n  }, [index, reduced, left, right, drop]);\n\n  const body = useDerivedValue(() => {\n    const x0 = PAD + left.get() * segment;\n    const x1 = PAD + right.get() * segment;\n    const w = Math.max(0, x1 - x0);\n    // Conserve volume: the more it stretches, the thinner it gets.\n    const stretch = segment > 0 ? Math.min(1, segment / Math.max(w, 1)) : 1;\n    const h = (HEIGHT - PAD * 2) * (0.62 + 0.38 * stretch);\n    const y = (HEIGHT - h) / 2;\n    return rrect(rect(x0, y, w, h), h / 2, h / 2);\n  });\n  const dropX = useDerivedValue(() => PAD + drop.get() * segment);\n  const dropR = useDerivedValue(() => {\n    const centre = (left.get() + right.get()) / 2;\n    // The droplet shows only while it lags behind the body.\n    const lag = Math.abs(drop.get() - centre);\n    return Math.min(1, lag * 2) * (HEIGHT - PAD * 2) * 0.3;\n  });\n\n  return (\n    <View\n      accessibilityRole=\"tablist\"\n      accessibilityLabel={accessibilityLabel}\n      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}\n      style={[\n        styles.bar,\n        { backgroundColor: theme.colors.track, borderRadius: theme.radius.pill },\n        style,\n      ]}\n    >\n      {segment > 0 && (\n        <View pointerEvents=\"none\" style={StyleSheet.absoluteFill}>\n          <Canvas style={StyleSheet.absoluteFill}>\n            <Group\n              layer={\n                <Paint>\n                  <Blur blur={goo ? 5 : 0} />\n                  <ColorMatrix matrix={goo ? GOO : IDENTITY} />\n                </Paint>\n              }\n            >\n              <RoundedRect rect={body} color={fill} />\n              {goo && <Circle cx={dropX} cy={HEIGHT / 2} r={dropR} color={fill} />}\n            </Group>\n          </Canvas>\n        </View>\n      )}\n      {tabs.map((tab, i) => (\n        <TabLabel\n          key={tab.key}\n          tab={tab}\n          i={i}\n          selected={i === index}\n          left={left}\n          right={right}\n          inactive={theme.colors.textMuted}\n          active={onFill}\n          onPress={() => {\n            if (tab.key === value) return;\n            haptic('tick');\n            onChange(tab.key);\n          }}\n        />\n      ))}\n    </View>\n  );\n}\n\n// CanvasKit (web) renders the alpha-threshold layer fully transparent, so the\n// web keeps the stretch but skips the goo filter and droplet.\nconst GOO_SUPPORTED = Platform.OS !== 'web';\n\nconst IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];\n\nfunction TabLabel({\n  tab,\n  i,\n  selected,\n  left,\n  right,\n  inactive,\n  active,\n  onPress,\n}: {\n  tab: LiquidTab;\n  i: number;\n  selected: boolean;\n  left: SharedValue<number>;\n  right: SharedValue<number>;\n  inactive: string;\n  active: string;\n  onPress: () => void;\n}) {\n  const { font } = useKinetikTheme();\n  // Label colour follows how much of the tab the liquid currently covers.\n  const textStyle = useAnimatedStyle(() => {\n    const cover = Math.max(0, Math.min(i + 1, right.get()) - Math.max(i, left.get()));\n    return { color: interpolateColor(Math.min(1, cover), [0, 1], [inactive, active]) };\n  });\n  return (\n    <Pressable\n      accessibilityRole=\"tab\"\n      accessibilityState={{ selected }}\n      accessibilityLabel={tab.label}\n      onPress={onPress}\n      style={styles.tab}\n    >\n      <Animated.Text\n        numberOfLines={1}\n        style={[{ fontSize: font.size.sm, fontWeight: font.weight.semibold }, textStyle]}\n      >\n        {tab.label}\n      </Animated.Text>\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  bar: { flexDirection: 'row', height: HEIGHT, padding: PAD, minHeight: minTouchTarget },\n  tab: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "interaction",
    "web": "live",
    "perf": {
      "budget": "While switching, three springs drive one small blurred layer (body and droplet); nothing runs at rest.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 18,
          "p90Ms": 23,
          "baselineMs": 20,
          "jankyPercent": 4.17,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "The indicator slides without stretching, droplet or goo filter.",
      "screenReader": "Tab list with tabs that expose their selected state.",
      "touchTarget": "Each tab spans its share of the bar and is 44 pt tall."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { LiquidTabs } from '@/components/kinetik/liquid-tabs';\n\nconst [tab, setTab] = useState('for-you');\n\n<LiquidTabs\n  accessibilityLabel=\"Feed\"\n  tabs={[\n    { key: 'for-you', label: 'For you' },\n    { key: 'following', label: 'Following' },\n    { key: 'nearby', label: 'Nearby' },\n  ]}\n  value={tab}\n  onChange={setTab}\n/>;\n```\n\n## When to use\n\n- Two to five peer views of the same content: feeds, filters, time ranges.\n- Top-of-screen switches where a little fluidity adds character.\n\n## When not to use\n\n- More than five options or long labels; use a scrolling tab bar or a menu.\n- Primary app navigation between unrelated sections. Use a bottom bar (see Dock Bar).\n\n## Notes\n\n- The leading edge uses the snappy spring and the trailing edge the smooth one; that difference is the liquid stretch.\n- Labels change colour by how much of their tab the liquid covers, so the colour sweeps with the motion.\n- Pass `color` and `activeTextColor` together to keep the label readable on the indicator.\n- On the web the indicator stretches but skips the droplet and goo filter, which CanvasKit does not render.\n"
  },
  "docs": "Prefer `npx kinetik-ui add liquid-tabs`: it installs dependencies with expo install, so versions match your Expo SDK."
}
