{"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":"blocks","source_url":"https://beui.dev/r/dynamic-island/raw","detail_url":"https://beui.dev/r/dynamic-island","raw_url":"https://beui.dev/r/dynamic-island/raw","page_url":"https://beui.dev/components/blocks/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  useLayoutEffect,\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 in Apple's duration/bounce form (per Emil Kowalski's island\n// lesson): one long perceptual glide with barely-there bounce, identical in\n// both directions. The shell animates real width/height (not transforms), so\n// slots are never scale-distorted.\nconst SHELL_SPRING = {\n  type: \"spring\",\n  duration: 0.8,\n  bounce: 0.2,\n} as const;\n\n// Content gets a touch more life than the shell.\nconst CONTENT_SPRING = {\n  type: \"spring\",\n  duration: 0.8,\n  bounce: 0.35,\n} as const;\n\n// Constant radius — never animated. The browser clamps it to half the shell\n// height, so the pill-to-rounded-rect morph falls out of the resize for free\n// with zero chance of corner glitches.\nconst RADIUS = 32;\n\n// iPhone pill dimensions. Also the shell's pre-measure animate target: if the\n// first commit already has a view active (e.g. a click replayed after\n// hydration), the shell blooms from the pill instead of rendering expanded\n// with no animation. Lives in `animate`, not `initial`, so server and client\n// markup agree.\nconst PILL_WIDTH = 126;\nconst PILL_HEIGHT = 37;\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  // Synchronous mount measure: the shell must own explicit dimensions before\n  // the first interaction. ResizeObserver fires async after mount — a quick\n  // first press could beat it, leaving the shell auto-sized so the view\n  // snapped open instead of springing.\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    setSize({ width: el.offsetWidth, height: el.offsetHeight });\n  }, []);\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}: {\n  keyId: string;\n  children: ReactNode;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      key={keyId}\n      initial={\n        reduce\n          ? { opacity: 0, filter: \"blur(0px)\" }\n          : { opacity: 0, scale: 0.9, y: -8, filter: \"blur(5px)\" }\n      }\n      animate={\n        reduce\n          ? { opacity: 1, filter: \"blur(0px)\" }\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, filter: \"blur(0px)\", transition: { duration: 0.1 } }\n          : {\n              opacity: 0,\n              scale: 0.9,\n              y: -6,\n              filter: \"blur(0px)\",\n              transition: { duration: 0.08, ease: EASE_OUT },\n            }\n      }\n      // One spring drives transform, opacity and blur together — no per\n      // property tweens, no delays. Content travels with the shell.\n      transition={reduce ? { duration: 0.15 } : CONTENT_SPRING}\n      // Anchored to the pill line: content unfurls downward out of it and is\n      // sucked back up into it. will-change pre-promotes the layer so the\n      // first blur/transform pass doesn't rasterize mid-animation.\n      style={{ transformOrigin: \"top center\", willChange: \"transform, opacity, filter\" }}\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            ? { width: size.width, height: size.height }\n            : { width: PILL_WIDTH, height: PILL_HEIGHT }\n        }\n        transition={reduce ? { duration: 0 } : SHELL_SPRING}\n        style={{ borderRadius: RADIUS }}\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                // 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/blocks/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 { animate, 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  /** Add a small blur during digit rolls. */\n  blur?: boolean;\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  blur = false,\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              blur={blur}\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  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\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        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\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 {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  forwardRef,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, EASE_OUT_CSS, 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\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  initial: { opacity: 0, y: 14, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    y: -14,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center will-change-[opacity,filter,transform]\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((currentWidth) =>\n      currentWidth === nextWidth ? currentWidth : nextWidth,\n    );\n  });\n\n  return (\n    <span\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n      style={{\n        width,\n        transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,\n      }}\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </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  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\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 gap-2 overflow-hidden\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\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"}]}