{"slug":"dynamic-island","name":"Dynamic Island","description":"iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.","category":"motion","source_url":"https://beui.saura3h.xyz/r/dynamic-island/raw","detail_url":"https://beui.saura3h.xyz/r/dynamic-island","raw_url":"https://beui.saura3h.xyz/r/dynamic-island/raw","page_url":"https://beui.saura3h.xyz/components/motion/dynamic-island","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["../magnetic","./base","./magnetic","./stateful","@/components/motion/button","@/components/motion/dynamic-island","@/components/motion/number-ticker","@/lib/ease","@/lib/hooks/use-hover-capable","@/lib/utils"],"files":[{"path":"components/motion/dynamic-island.tsx","type":"component","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype IslandContextValue = {\n  view: string | null;\n};\n\nconst IslandContext = createContext<IslandContextValue | null>(null);\n\n// Shell physics, Apple style: expansion blooms out of the pill with a visible\n// overshoot, and collapse returns with the same life — the pill squeezes a\n// touch past its size and springs back. The shell animates real width/height\n// (not transforms), so slots are never scale-distorted.\nconst EXPAND_SPRING = {\n  type: \"spring\",\n  stiffness: 550,\n  damping: 25,\n  mass: 0.5,\n} as const;\n\nconst COLLAPSE_SPRING = {\n  type: \"spring\",\n  stiffness: 520,\n  damping: 24,\n  mass: 0.45,\n} as const;\n\n// Content pops from the pill core just after the shell starts moving.\nconst CONTENT_SPRING = {\n  type: \"spring\",\n  stiffness: 560,\n  damping: 28,\n  mass: 0.5,\n} as const;\n\n// Real radii, tweened separately from the size spring. Springing between a\n// fake huge radius and a small one makes corners glitch mid-resize; these two\n// values are close (18.5 is exactly half the 37px pill) so the corner shape\n// stays stable throughout.\nconst RADIUS_COMPACT = 18.5;\nconst RADIUS_EXPANDED = 24;\n\n/** Tracks the natural size of the content so the shell can spring to it. */\nfunction useContentSize() {\n  const ref = useRef<HTMLDivElement | null>(null);\n  const [size, setSize] = useState<{ width: number; height: number } | null>(null);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      setSize({ width: el.offsetWidth, height: el.offsetHeight });\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  return [ref, size] as const;\n}\n\nfunction Slot({\n  keyId,\n  children,\n  className,\n  scaleFrom = 0.8,\n  delay = 0.04,\n}: {\n  keyId: string;\n  children: ReactNode;\n  className?: string;\n  /** Scale the content emerges from — everything originates at the pill. */\n  scaleFrom?: number;\n  /** Lets the shell lead the bloom before content appears. */\n  delay?: number;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      key={keyId}\n      initial={\n        reduce\n          ? { opacity: 0 }\n          : { opacity: 0, scale: scaleFrom, y: -8, filter: \"blur(8px)\" }\n      }\n      animate={\n        reduce\n          ? { opacity: 1 }\n          : { opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }\n      }\n      // Exit gets sucked up into the pill — fast, blur-free, before the\n      // shrinking shell can clip it.\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.1 } }\n          : {\n              opacity: 0,\n              scale: 0.85,\n              y: -6,\n              transition: { duration: 0.08, ease: EASE_OUT },\n            }\n      }\n      transition={\n        reduce\n          ? { duration: 0.15 }\n          : {\n              ...CONTENT_SPRING,\n              delay,\n              opacity: { duration: 0.18, ease: EASE_OUT, delay },\n              filter: { duration: 0.22, ease: EASE_OUT, delay },\n            }\n      }\n      // Anchored to the pill line: content unfurls downward out of it and is\n      // sucked back up into it.\n      style={{ transformOrigin: \"top center\" }}\n      className={cn(\"flex items-center justify-center\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport interface DynamicIslandProps {\n  /** Active view id. `null` shows the compact pill. */\n  view: string | null;\n  /** Compact pill content, shown when no view is active. */\n  compact?: ReactNode;\n  /** DynamicIslandView elements. */\n  children?: ReactNode;\n  className?: string;\n}\n\nexport function DynamicIsland({\n  view,\n  compact,\n  children,\n  className,\n}: DynamicIslandProps) {\n  const reduce = useReducedMotion();\n  const expanded = view !== null;\n  const [sizerRef, size] = useContentSize();\n\n  return (\n    <IslandContext.Provider value={{ view }}>\n      <motion.div\n        role=\"status\"\n        aria-live=\"polite\"\n        initial={false}\n        animate={\n          size\n            ? {\n                width: size.width,\n                height: size.height,\n                borderRadius: expanded ? RADIUS_EXPANDED : RADIUS_COMPACT,\n              }\n            : { borderRadius: expanded ? RADIUS_EXPANDED : RADIUS_COMPACT }\n        }\n        transition={\n          reduce\n            ? { duration: 0 }\n            : {\n                ...(expanded ? EXPAND_SPRING : COLLAPSE_SPRING),\n                borderRadius: { duration: 0.2, ease: EASE_OUT },\n              }\n        }\n        // items-start pins content to the top edge while the shell springs, so\n        // expansion reads as unfurling downward out of the pill. Top-align the\n        // island in its parent (like under a notch) to complete the effect.\n        className={cn(\n          \"relative inline-flex items-start justify-center overflow-hidden\",\n          \"bg-foreground text-background shadow-2xl\",\n          className,\n        )}\n      >\n        {/* w-max keeps this at the natural size of the active content; the\n            shell springs toward it. */}\n        <div ref={sizerRef} className=\"w-max\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {!expanded && compact ? (\n              <Slot\n                keyId=\"compact\"\n                scaleFrom={0.85}\n                delay={0.06}\n                // iPhone pill proportions: ~126 x 37.\n                className=\"min-h-[37px] min-w-[126px] gap-2 px-4 py-1.5 text-xs font-medium\"\n              >\n                {compact}\n              </Slot>\n            ) : null}\n          </AnimatePresence>\n          {children}\n        </div>\n      </motion.div>\n    </IslandContext.Provider>\n  );\n}\n\nexport interface DynamicIslandViewProps {\n  /** Matches the parent `view` prop when active. */\n  id: string;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function DynamicIslandView({ id, children, className }: DynamicIslandViewProps) {\n  const ctx = useContext(IslandContext);\n  if (!ctx) throw new Error(\"DynamicIslandView must be used inside <DynamicIsland>\");\n  const active = ctx.view === id;\n\n  return (\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      {active ? (\n        <Slot keyId={id} className={cn(\"px-6 py-4\", className)}>\n          {children}\n        </Slot>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/dynamic-island.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Music, Phone, PhoneOff, Timer } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport { DynamicIsland, DynamicIslandView } from \"@/components/motion/dynamic-island\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\n\ntype IslandView = \"call\" | \"timer\" | \"music\" | null;\n\nconst BAR_DELAYS = [0, 0.18, 0.09, 0.27];\n\nfunction EqBars() {\n  const reduce = useReducedMotion();\n  return (\n    <span className=\"flex h-4 items-end gap-0.5\" aria-hidden>\n      {BAR_DELAYS.map((delay) => (\n        <motion.span\n          key={delay}\n          animate={reduce ? undefined : { scaleY: [0.4, 1, 0.55, 0.9, 0.4] }}\n          transition={{ duration: 1.1, repeat: Infinity, ease: \"easeInOut\", delay }}\n          className=\"h-full w-0.5 origin-bottom rounded-full bg-(--color-success)\"\n          style={{ scaleY: 0.6 }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction formatClock(totalSeconds: number) {\n  const m = Math.floor(totalSeconds / 60);\n  const s = totalSeconds % 60;\n  return `${m}:${String(s).padStart(2, \"0\")}`;\n}\n\nexport function DynamicIslandPreview() {\n  const [view, setView] = useState<IslandView>(null);\n  const [seconds, setSeconds] = useState(154);\n\n  useEffect(() => {\n    if (view !== \"timer\") return;\n    const id = window.setInterval(() => {\n      setSeconds((s) => (s > 0 ? s - 1 : 0));\n    }, 1000);\n    return () => window.clearInterval(id);\n  }, [view]);\n\n  return (\n    <div className=\"flex w-full flex-col items-center gap-4\">\n      {/* Fixed-height, top-aligned zone: the island stays pinned at the top\n          like under a notch and unfurls downward into reserved space. */}\n      <div className=\"flex h-32 w-full items-start justify-center pt-2\">\n        <DynamicIsland\n        view={view}\n        compact={\n          <>\n            <span className=\"h-1.5 w-1.5 rounded-full bg-(--color-success)\" />\n            <span>9:41</span>\n          </>\n        }\n      >\n        <DynamicIslandView id=\"call\" className=\"gap-4\">\n          <div className=\"flex flex-col\">\n            <span className=\"text-[10px] uppercase tracking-wider opacity-60\">\n              Incoming call\n            </span>\n            <span className=\"text-sm font-semibold\">Emil Kowalski</span>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <button\n              type=\"button\"\n              aria-label=\"Decline\"\n              onClick={() => setView(null)}\n              className=\"flex h-8 w-8 items-center justify-center rounded-full bg-(--color-danger) text-white\"\n            >\n              <PhoneOff className=\"h-3.5 w-3.5\" />\n            </button>\n            <button\n              type=\"button\"\n              aria-label=\"Accept\"\n              onClick={() => setView(null)}\n              className=\"flex h-8 w-8 items-center justify-center rounded-full bg-(--color-success) text-white\"\n            >\n              <Phone className=\"h-3.5 w-3.5\" />\n            </button>\n          </div>\n        </DynamicIslandView>\n\n        <DynamicIslandView id=\"timer\" className=\"gap-3\">\n          <Timer className=\"h-4 w-4 text-(--color-warning)\" />\n          <span className=\"text-[10px] uppercase tracking-wider opacity-60\">Timer</span>\n          <NumberTicker\n            value={seconds}\n            format={formatClock}\n            startOnView={false}\n            duration={0.5}\n            className=\"text-sm font-semibold\"\n          />\n        </DynamicIslandView>\n\n        <DynamicIslandView id=\"music\" className=\"gap-3\">\n          <span className=\"flex h-7 w-7 items-center justify-center rounded-lg bg-background/15\">\n            <Music className=\"h-3.5 w-3.5\" />\n          </span>\n          <div className=\"flex flex-col text-left\">\n            <span className=\"text-xs font-semibold leading-tight\">Midnight City</span>\n            <span className=\"text-[10px] opacity-60\">M83</span>\n          </div>\n          <EqBars />\n        </DynamicIslandView>\n        </DynamicIsland>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        <Button size=\"sm\" variant=\"secondary\" onClick={() => setView(\"call\")}>\n          Call\n        </Button>\n        <Button\n          size=\"sm\"\n          variant=\"secondary\"\n          onClick={() => {\n            setSeconds(154);\n            setView(\"timer\");\n          }}\n        >\n          Timer\n        </Button>\n        <Button size=\"sm\" variant=\"secondary\" onClick={() => setView(\"music\")}>\n          Music\n        </Button>\n        <Button size=\"sm\" variant=\"ghost\" onClick={() => setView(null)}>\n          Dismiss\n        </Button>\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"util","content":"export { Button } from \"./base\";\nexport type { ButtonProps, ButtonVariant, ButtonSize } from \"./base\";\n\nexport { StatefulButton } from \"./stateful\";\nexport type { StatefulButtonProps, ButtonState } from \"./stateful\";\n\nexport { MagneticButton } from \"./magnetic\";\nexport type { MagneticButtonProps } from \"./magnetic\";\n"},{"path":"components/motion/number-ticker.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString()\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={reduce ? { duration: 0 } : { duration, delay, ease: EASE_OUT }}\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center\"\n      >\n        {DIGITS.map((n) => (\n          <span key={n} className=\"flex h-[1.1em] items-center justify-center leading-none\">\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/button/base.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion, type HTMLMotionProps } from \"motion/react\";\nimport { forwardRef, type ReactNode } from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<HTMLMotionProps<\"button\">, \"children\"> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary:\n    \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(\n  { variant = \"primary\", size = \"md\", pressScale = 0.93, className, children, ...rest },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      whileTap={reduce ? undefined : { scale: pressScale }}\n      whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center font-medium select-none\",\n        \"transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </motion.button>\n  );\n});\n"},{"path":"components/motion/button/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, Loader2, X } from \"lucide-react\";\nimport { forwardRef, type ReactNode } from \"react\";\nimport { SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nfunction Slot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      initial={reduce ? { opacity: 0 } : { y: 14, opacity: 0, filter: \"blur(6px)\" }}\n      animate={reduce ? { opacity: 1 } : { y: 0, opacity: 1, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0 } : { y: -14, opacity: 0, filter: \"blur(6px)\" }}\n      transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n      className=\"inline-flex items-center gap-2 whitespace-nowrap\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const isBusy = state === \"loading\";\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} {...rest}>\n      <motion.span\n        layout={!reduce}\n        transition={SPRING_SWAP}\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {state === \"idle\" ? (\n            <Slot keyId=\"idle\">\n              {children}\n              {icon}\n            </Slot>\n          ) : null}\n          {state === \"loading\" ? (\n            <Slot keyId=\"loading\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n              {loadingText}\n            </Slot>\n          ) : null}\n          {state === \"success\" ? (\n            <Slot keyId=\"success\">\n              <Check className=\"h-4 w-4\" />\n              {successText}\n            </Slot>\n          ) : null}\n          {state === \"error\" ? (\n            <Slot keyId=\"error\">\n              <X className=\"h-4 w-4\" />\n              {errorText}\n            </Slot>\n          ) : null}\n        </AnimatePresence>\n      </motion.span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}