{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-tabs","type":"registry:component","title":"Tabs (Motion)","description":"Tabs with motion: the picked tab's card springs to the one you pick, a soft highlight glides after the pointer, the new panel slides in from that side while the height glides, and swipeable panels follow your finger.","dependencies":["motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/motion-hover.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/tabs.json"],"files":[{"path":"registry/dashral/motion/tabs.tsx","type":"registry:file","target":"components/dashral/motion/tabs.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Tabs as TabsPrimitive } from \"radix-ui\"\nimport { animate, LayoutGroup, motion, useMotionValue } from \"motion/react\"\nimport type { TabsProps as KitTabsProps } from \"../tabs\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\n\nimport { useHoverPill } from \"../lib/motion-hover\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type { TabItem } from \"../tabs\"\n\nexport type TabsProps = KitTabsProps & {\n  /**\n   * Swipe a panel sideways to go to the next or previous tab, as on a phone; at the first or last tab it holds back\n   * like a rubber band. Off by default, since a drag inside a panel may mean something else (selecting text).\n   */\n  swipeable?: boolean\n}\n\n/** A light buzz on phones that have one. */\nconst buzz = (pattern: number | number[]) => {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) navigator.vibrate(pattern)\n}\n\nconst trigger =\n  \"relative inline-flex h-full flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 hover:text-foreground data-[state=active]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n/**\n * The kit's Tabs with motion: the same items, variants and props. The picked tab's card (or line) springs to the tab\n * you pick, a soft highlight glides after the pointer from tab to tab without a break, the new panel slides in from the side of the tab\n * you came from, and the panels' height glides between them instead of jumping. With reduced motion everything only\n * fades. With swipeable, a panel follows a sideways drag and lets go into the next or previous tab, holding back like a\n * rubber band at the first and last; phones buzz as it switches. `orientation=\"vertical\"`: the tabs in a column beside\n * their panel, the card or the start-edge line springing up and down, the panel arriving from above or below (no swipe).\n */\nexport function Tabs({\n  items,\n  variant = \"default\",\n  fullWidth,\n  listAction,\n  defaultValue,\n  value,\n  onValueChange,\n  swipeable = false,\n  orientation = \"horizontal\",\n  className,\n  classNames,\n  children,\n  ...props\n}: TabsProps) {\n  const vertical = orientation === \"vertical\"\n  // A sideways swipe through a column of tabs would go against its grain.\n  const swipes = swipeable && !vertical\n  const reduced = useReducedMotion()\n  const id = React.useId()\n  const [own, setOwn] = React.useState(defaultValue ?? items[0]?.value ?? \"\")\n  const active = value ?? own\n  // The pointer's highlight: one pill gliding from tab to tab without going out between them; it fades where it is\n  // when the pointer leaves the row.\n  const hover = useHoverPill<string>(reduced)\n  // Which way the panel arrives: from the right when you move right, from the left when you move left.\n  const order = items.map((item) => item.value)\n  const previous = React.useRef(active)\n  const direction = Math.sign(order.indexOf(active) - order.indexOf(previous.current))\n  React.useEffect(() => {\n    previous.current = active\n  }, [active])\n\n  // The tabs a swipe can reach, in order, skipping disabled ones.\n  const reachable = items.filter((item) => !item.disabled).map((item) => item.value)\n  const neighbour = (step: 1 | -1) => reachable[reachable.indexOf(active) + step]\n  const rtl = useLocale().dir === \"rtl\"\n  const swiped = (offset: number, velocity: number, pointer: string) => {\n    const toward = (offset < -60 || velocity < -400 ? 1 : offset > 60 || velocity > 400 ? -1 : 0) * (rtl ? -1 : 1)\n    const next = toward ? neighbour(toward as 1 | -1) : undefined\n    if (pointer === \"touch\") buzz(next ? 8 : [6, 30, 6])\n    if (next) pick(next)\n  }\n\n  const pick = (next: string) => {\n    if (value === undefined) setOwn(next)\n    onValueChange?.(next)\n  }\n\n  // The panels' height follows the open one's, on a glide; before it's measured (and with reduced motion) it's natural.\n  const panel = React.useRef<HTMLDivElement>(null)\n  const height = useMotionValue<number | \"auto\">(\"auto\")\n  // Clipped only while it glides, so a focus ring at the panel's edge isn't cut off the rest of the time.\n  const [gliding, setGliding] = React.useState(false)\n  React.useEffect(() => {\n    const node = panel.current\n    if (!node) return\n    let first = true\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize[0]?.blockSize ?? node.offsetHeight\n      if (first || reduced) height.jump(next)\n      else {\n        setGliding(true)\n        animate(height, next, { ...spring.glide, onComplete: () => setGliding(false) })\n      }\n      first = false\n    })\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [height, reduced])\n\n  // A row of tabs wider than its space (a phone) scrolls sideways, and keeps the picked tab in sight; only then, so a row\n  // that fits keeps its focus rings unclipped. Only the row moves, never the page.\n  const list = React.useRef<HTMLDivElement>(null)\n  const [overflowing, setOverflowing] = React.useState(false)\n  React.useLayoutEffect(() => {\n    const node = list.current\n    if (!node || vertical) return\n    const fit = () => {\n      const wide = node.scrollWidth > node.clientWidth + 1\n      setOverflowing(wide)\n      const tab = node.querySelector<HTMLElement>(\":scope > [data-state=active]\")\n      if (!tab || !wide) return\n      if (tab.offsetLeft < node.scrollLeft) node.scrollLeft = tab.offsetLeft\n      else if (tab.offsetLeft + tab.offsetWidth > node.scrollLeft + node.clientWidth) node.scrollLeft = tab.offsetLeft + tab.offsetWidth - node.clientWidth\n    }\n    fit()\n    const observer = new ResizeObserver(fit)\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [vertical, active])\n\n  // Whether there's a tab to swipe to on that side of the panel (left is next in a left-to-right page).\n  const neighbourAt = (side: \"left\" | \"right\") => {\n    return Boolean(neighbour((side === \"left\") !== rtl ? 1 : -1))\n  }\n\n  const line = variant === \"line\"\n  const move = reduced ? { duration: 0 } : spring.move\n  const hasPanels = items.some((item) => item.content !== undefined)\n\n  return (\n    <TabsPrimitive.Root\n      data-slot=\"tabs\"\n      orientation={orientation}\n      value={active}\n      onValueChange={pick}\n      className={cn(\"flex flex-col gap-2\", vertical && \"flex-row gap-6\", className)}\n      {...props}\n    >\n      <div\n        data-slot=\"tabs-header\"\n        className={cn(\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2\", vertical && \"flex-col flex-nowrap items-stretch\", classNames?.header)}\n      >\n        <LayoutGroup id={id}>\n          <TabsPrimitive.List\n            ref={list}\n            data-slot=\"tabs-list\"\n            data-variant={variant}\n            data-overflowing={overflowing ? \"\" : undefined}\n            onPointerLeave={hover.leave}\n            className={cn(\n              \"relative isolate inline-flex w-fit items-center justify-center text-muted-foreground\",\n              // Wider than its space (a phone): the row scrolls sideways, without a scrollbar, instead of running out of it.\n              !vertical && \"max-w-full data-overflowing:justify-start data-overflowing:overflow-x-auto data-overflowing:[scrollbar-width:none] data-overflowing:[&::-webkit-scrollbar]:hidden\",\n              line ? \"h-10 gap-5\" : \"h-9 rounded-lg bg-muted p-[3px]\",\n              // A column: the tabs stacked; line tabs on a hairline at their start edge.\n              vertical && \"h-auto flex-col items-stretch justify-start\",\n              vertical && line && \"gap-0 border-s border-border\",\n              fullWidth && \"w-full\",\n              classNames?.list\n            )}\n          >\n            {items.map((item) => (\n              <TabsPrimitive.Trigger\n                key={item.value}\n                value={item.value}\n                disabled={item.disabled}\n                onPointerEnter={() => hover.enter(item.value)}\n                className={cn(\n                  trigger,\n                  line && \"flex-none rounded-none px-0.5\",\n                  vertical && \"h-auto justify-start py-1.5 text-start whitespace-normal\",\n                  vertical && line && \"py-2 ps-4 pe-1\",\n                  classNames?.trigger\n                )}\n              >\n                {/* The picked tab's card or line: one element that springs from tab to tab (motion's shared layout). */}\n                {active === item.value && (\n                  <motion.span\n                    layoutId=\"picked\"\n                    aria-hidden\n                    transition={move}\n                    className={cn(\n                      \"absolute -z-10\",\n                      line\n                        ? vertical\n                          ? \"inset-y-0 -start-px w-0.5 rounded-full bg-foreground\"\n                          : \"inset-x-0 -bottom-px h-0.5 rounded-full bg-foreground\"\n                        : \"inset-0 rounded-md border border-border bg-card shadow-sm dark:border-transparent dark:bg-input\"\n                    )}\n                  />\n                )}\n                {/* A softer one under the pointer, one element gliding from tab to tab without a break: it passes under the\n                    picked tab's card too (hidden there) rather than vanishing and starting over. It fades where it is when\n                    the pointer leaves the row. */}\n                {hover.hot === item.value && !line && (\n                  <motion.span\n                    layoutId=\"hovered\"\n                    aria-hidden\n                    // Its opacity is the row's, so it doesn't start from nothing at each new tab.\n                    style={{ opacity: hover.opacity }}\n                    transition={move}\n                    className=\"absolute inset-0 -z-20 rounded-md bg-foreground/5\"\n                  />\n                )}\n                {item.icon}\n                {item.label}\n                {item.badge}\n              </TabsPrimitive.Trigger>\n            ))}\n          </TabsPrimitive.List>\n        </LayoutGroup>\n        {listAction}\n      </div>\n      {hasPanels && (\n        <motion.div style={{ height }} className={cn((gliding || swipes) && \"overflow-hidden\")}>\n          <div ref={panel}>\n            {items.map((item) =>\n              item.content === undefined || item.value !== active ? null : (\n                <TabsPrimitive.Content key={item.value} value={item.value} asChild>\n                  <motion.div\n                    className={cn(\"outline-none\", swipes && \"touch-pan-y\", classNames?.content)}\n                    // A swipe: free toward a neighbouring tab, a rubber band toward an end with none.\n                    drag={swipes && !reduced ? \"x\" : false}\n                    dragDirectionLock\n                    dragSnapToOrigin\n                    dragConstraints={{ left: 0, right: 0 }}\n                    dragElastic={{ left: neighbourAt(\"left\") ? 0.5 : 0.12, right: neighbourAt(\"right\") ? 0.5 : 0.12 }}\n                    onDragEnd={(event, info) => swiped(info.offset.x, info.velocity.x, (event as PointerEvent).pointerType ?? \"mouse\")}\n                    // From the side of the tab you came from: above or below in a column.\n                    initial={reduced ? { opacity: 0 } : vertical ? { opacity: 0, y: direction * 12 } : { opacity: 0, x: direction * 16 }}\n                    animate={{ opacity: 1, x: 0, y: 0 }}\n                    transition={reduced ? { duration: duration.fast } : { duration: duration.standard, ease: ease.enter }}\n                  >\n                    {item.content}\n                  </motion.div>\n                </TabsPrimitive.Content>\n              )\n            )}\n          </div>\n        </motion.div>\n      )}\n      {children}\n    </TabsPrimitive.Root>\n  )\n}\n"}]}