{"slug":"empty-state","name":"Empty State","description":"Animated empty-state blocks with hand-drawn line-art illustrations, concise copy and a primary action — an inbox-zero envelope, a hover-to-open archive cabinet, and a scanning search lens.","category":"blocks","source_url":"https://ui-lab-ten.vercel.app/r/empty-state/raw","detail_url":"https://ui-lab-ten.vercel.app/r/empty-state","raw_url":"https://ui-lab-ten.vercel.app/r/empty-state/raw","page_url":"https://ui-lab-ten.vercel.app/components/blocks/empty-state","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["../magnetic","./archive","./base","./inbox","./magnetic","./search","./shared","./stateful","@/components/motion/button","@/lib/ease","@/lib/hooks/use-hover-capable","@/lib/utils"],"files":[{"path":"components/motion/empty-state/index.tsx","type":"component","content":"// ui-lab-ten.vercel.app/components/blocks/empty-state\nexport { EmptyStateArchive } from \"./archive\";\nexport { EmptyStateInbox } from \"./inbox\";\nexport { EmptyStateSearch } from \"./search\";\nexport {\n  EmptyStateAction,\n  EmptyStateCopy,\n  EmptyStateStage,\n  type EmptyStateProps,\n} from \"./shared\";\n"},{"path":"components/motion/empty-state/shared.tsx","type":"component","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/empty-state\n\nimport type { ReactNode } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface EmptyStateProps {\n  title?: string;\n  message?: string;\n  actionLabel?: string;\n  onAction?: () => void;\n  className?: string;\n}\n\n/** Centers an illustration, copy, and action into a consistent column. */\nexport function EmptyStateStage({\n  className,\n  children,\n}: {\n  className?: string;\n  children: ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex w-full flex-col items-center justify-center gap-6 px-6 py-12 text-center\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\n/** Title (semibold) + muted, width-capped message — shared copy block. */\nexport function EmptyStateCopy({\n  title,\n  message,\n}: {\n  title: string;\n  message: string;\n}) {\n  return (\n    <div className=\"flex flex-col items-center gap-2\">\n      <p className=\"text-lg font-semibold text-foreground\">{title}</p>\n      <p className=\"max-w-xs text-sm text-muted-foreground\">{message}</p>\n    </div>\n  );\n}\n\ntype EmptyStateActionProps = Pick<EmptyStateProps, \"actionLabel\" | \"onAction\">;\n\n/** Primary CTA. Renders nothing when no label is given. */\nexport function EmptyStateAction({\n  actionLabel,\n  onAction,\n}: EmptyStateActionProps) {\n  if (!actionLabel) return null;\n  return (\n    <Button variant=\"primary\" onClick={onAction}>\n      {actionLabel}\n    </Button>\n  );\n}\n"},{"path":"components/motion/empty-state/inbox.tsx","type":"component","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/empty-state\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport {\n  EmptyStateAction,\n  EmptyStateCopy,\n  EmptyStateStage,\n  type EmptyStateProps,\n} from \"./shared\";\n\nconst INBOX_DEFAULTS = {\n  title: \"Inbox zero\",\n  message: \"You're all caught up. New replies will land here.\",\n  actionLabel: \"Compose\",\n} as const;\n\n// Sequence: the flap lifts first, the letter rises out shortly after (while the\n// flap is still opening), then the badge springs in once the letter has landed.\nconst FLAP_DELAY = 0.05;\nconst LETTER_DELAY = 0.28;\nconst BADGE_DELAY = 0.58;\nconst IDLE_DELAY = LETTER_DELAY + 0.5;\n\nexport function EmptyStateInbox({\n  title = INBOX_DEFAULTS.title,\n  message = INBOX_DEFAULTS.message,\n  actionLabel = INBOX_DEFAULTS.actionLabel,\n  onAction,\n  className,\n}: EmptyStateProps) {\n  const reduce = useReducedMotion();\n\n  return (\n    <EmptyStateStage className={className}>\n      <svg\n        viewBox=\"0 0 160 160\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden=\"true\"\n        className=\"h-40 w-40 text-muted-foreground\"\n      >\n        {/* Pocket: open-top envelope body. */}\n        <path d=\"M34 58 V118 L42 126 H118 L126 118 V58\" />\n\n        {/* Letter: lifts out of the pocket, then floats gently forever. */}\n        <motion.g\n          initial={reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 28 }}\n          animate={{ opacity: 1, y: -18 }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : { duration: 0.5, ease: EASE_OUT, delay: LETTER_DELAY }\n          }\n        >\n          <motion.g\n            animate={reduce ? undefined : { y: [0, -2, 0, 2, 0] }}\n            transition={\n              reduce\n                ? undefined\n                : {\n                    duration: 3.2,\n                    ease: EASE_IN_OUT,\n                    repeat: Infinity,\n                    delay: IDLE_DELAY,\n                  }\n            }\n          >\n            <rect x=\"58\" y=\"72\" width=\"44\" height=\"30\" rx=\"3\" />\n            <line x1=\"66\" y1=\"82\" x2=\"94\" y2=\"82\" />\n            <line x1=\"66\" y1=\"90\" x2=\"86\" y2=\"90\" />\n          </motion.g>\n        </motion.g>\n\n        {/* Flap: swings open like a hinge anchored at the pocket's top edge. */}\n        <motion.path\n          d=\"M34 58 L80 30 L126 58\"\n          style={{ transformOrigin: \"80px 58px\" }}\n          initial={reduce ? { scaleY: 1 } : { scaleY: 0 }}\n          animate={{ scaleY: 1 }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : { duration: 0.4, ease: EASE_OUT, delay: FLAP_DELAY }\n          }\n        />\n\n        {/* Badge: checkmark springs in once the inbox has settled. */}\n        <motion.g\n          className=\"text-primary\"\n          style={{ transformOrigin: \"128px 40px\" }}\n          initial={\n            reduce ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.4 }\n          }\n          animate={{ opacity: 1, scale: 1 }}\n          transition={\n            reduce ? { duration: 0 } : { ...SPRING_SWAP, delay: BADGE_DELAY }\n          }\n        >\n          <circle cx=\"128\" cy=\"40\" r=\"16\" />\n          <motion.path\n            d=\"M120 40l5 5l11-11\"\n            initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n            animate={{ pathLength: 1 }}\n            transition={\n              reduce\n                ? { duration: 0 }\n                : { duration: 0.3, ease: EASE_OUT, delay: BADGE_DELAY + 0.2 }\n            }\n          />\n        </motion.g>\n      </svg>\n      <EmptyStateCopy title={title} message={message} />\n      <EmptyStateAction actionLabel={actionLabel} onAction={onAction} />\n    </EmptyStateStage>\n  );\n}\n"},{"path":"components/motion/empty-state/archive.tsx","type":"component","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/empty-state\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport {\n  EmptyStateAction,\n  EmptyStateCopy,\n  EmptyStateStage,\n  type EmptyStateProps,\n} from \"./shared\";\n\nconst ARCHIVE_DEFAULTS = {\n  title: \"Nothing archived yet\",\n  message: \"Records you file away will show up here.\",\n  actionLabel: \"Add record\",\n} as const;\n\n// The top drawer slides along the same oblique \"depth\" axis used to draw the\n// cabinet's lid and side face, so it reads as pulling out toward the viewer.\nconst DRAWER_OPEN = { x: -9, y: 5 };\nconst DRAWER_SHUT = { x: 0, y: 0 };\n\nexport function EmptyStateArchive({\n  title = ARCHIVE_DEFAULTS.title,\n  message = ARCHIVE_DEFAULTS.message,\n  actionLabel = ARCHIVE_DEFAULTS.actionLabel,\n  onAction,\n  className,\n}: EmptyStateProps) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const [active, setActive] = useState(false);\n  const open = active && !reduce;\n\n  const handleEnter = () => {\n    if (!canHover) return;\n    setActive(true);\n  };\n  const handleLeave = () => {\n    if (!canHover) return;\n    setActive(false);\n  };\n\n  return (\n    <EmptyStateStage className={className}>\n      <button\n        type=\"button\"\n        aria-label=\"Archive cabinet\"\n        onMouseEnter={handleEnter}\n        onMouseLeave={handleLeave}\n        onFocus={() => setActive(true)}\n        onBlur={() => setActive(false)}\n        className=\"inline-flex items-center justify-center rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n      >\n        <svg\n          viewBox=\"0 0 160 160\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden=\"true\"\n          className=\"h-40 w-40 text-muted-foreground\"\n        >\n          {/* Lid: top face of the cabinet, oblique projection. */}\n          <path d=\"M40 50 L104 50 L124 38 L60 38 Z\" />\n          {/* Side: right face, sharing the lid's depth axis. */}\n          <path d=\"M104 50 L104 126 L124 114 L124 38 Z\" />\n\n          {/* Papers: peek up above the lid while the top drawer is pulled open. */}\n          <motion.g\n            animate={\n              open\n                ? { opacity: 1, y: 0 }\n                : { opacity: 0, y: reduce ? 0 : 10 }\n            }\n            transition={{ duration: 0.22, ease: EASE_OUT }}\n          >\n            <rect\n              x=\"54\"\n              y=\"14\"\n              width=\"18\"\n              height=\"24\"\n              rx=\"2\"\n              transform=\"rotate(-6 63 26)\"\n            />\n            <rect\n              x=\"76\"\n              y=\"16\"\n              width=\"18\"\n              height=\"24\"\n              rx=\"2\"\n              transform=\"rotate(5 85 28)\"\n            />\n          </motion.g>\n\n          {/* Top drawer: slides along the depth axis on hover/focus. */}\n          <motion.g\n            animate={open ? DRAWER_OPEN : DRAWER_SHUT}\n            transition={reduce ? { duration: 0 } : SPRING_PANEL}\n          >\n            <rect x=\"40\" y=\"50\" width=\"64\" height=\"37\" rx=\"2\" />\n            <line x1=\"62\" y1=\"69\" x2=\"82\" y2=\"69\" />\n          </motion.g>\n\n          {/* Bottom drawer: static. */}\n          <rect x=\"40\" y=\"89\" width=\"64\" height=\"37\" rx=\"2\" />\n          <line x1=\"62\" y1=\"107\" x2=\"82\" y2=\"107\" />\n        </svg>\n      </button>\n      <EmptyStateCopy title={title} message={message} />\n      <EmptyStateAction actionLabel={actionLabel} onAction={onAction} />\n    </EmptyStateStage>\n  );\n}\n"},{"path":"components/motion/empty-state/search.tsx","type":"component","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/empty-state\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport {\n  EmptyStateAction,\n  EmptyStateCopy,\n  EmptyStateStage,\n  type EmptyStateProps,\n} from \"./shared\";\n\nconst SEARCH_DEFAULTS = {\n  title: \"No matches\",\n  message: \"Try fewer filters or a different phrase.\",\n  actionLabel: \"Clear filters\",\n} as const;\n\n// Result rows the glass sweeps over; each flickers on its own staggered delay.\nconst ROW_Y = [112, 126, 140] as const;\n\nexport function EmptyStateSearch({\n  title = SEARCH_DEFAULTS.title,\n  message = SEARCH_DEFAULTS.message,\n  actionLabel = SEARCH_DEFAULTS.actionLabel,\n  onAction,\n  className,\n}: EmptyStateProps) {\n  const reduce = useReducedMotion();\n\n  return (\n    <EmptyStateStage className={className}>\n      <motion.svg\n        viewBox=\"0 0 160 160\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden=\"true\"\n        className=\"h-40 w-40 text-muted-foreground\"\n        initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.94 }}\n        animate={{ opacity: 1, scale: 1 }}\n        transition={\n          reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }\n        }\n      >\n        {/* Result rows: dashed lines that flicker in sequence, as if scanned and skipped. */}\n        {ROW_Y.map((y, i) => (\n          <motion.line\n            key={y}\n            x1={36}\n            y1={y}\n            x2={124}\n            y2={y}\n            strokeDasharray=\"4 6\"\n            animate={reduce ? undefined : { opacity: [1, 0.3, 1] }}\n            transition={\n              reduce\n                ? undefined\n                : {\n                    duration: 1.8,\n                    ease: EASE_IN_OUT,\n                    repeat: Infinity,\n                    delay: 0.5 + i * 0.35,\n                  }\n            }\n          />\n        ))}\n\n        {/* Glass: slow scan sweep, as if searching but never landing on a match. */}\n        <motion.g\n          style={{ transformOrigin: \"68px 62px\" }}\n          animate={\n            reduce\n              ? undefined\n              : { x: [0, 8, 0, -8, 0], rotate: [0, 4, 0, -4, 0] }\n          }\n          transition={\n            reduce\n              ? undefined\n              : {\n                  duration: 5.4,\n                  ease: EASE_IN_OUT,\n                  repeat: Infinity,\n                  delay: 0.4,\n                }\n          }\n        >\n          <circle cx=\"68\" cy=\"62\" r=\"30\" />\n          <line x1=\"90\" y1=\"84\" x2=\"114\" y2=\"108\" />\n        </motion.g>\n      </motion.svg>\n      <EmptyStateCopy title={title} message={message} />\n      <EmptyStateAction actionLabel={actionLabel} onAction={onAction} />\n    </EmptyStateStage>\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":"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":"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/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/button/base.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, 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<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"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>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id: nextId.current++,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\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        onPointerDown={handlePointerDown}\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          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\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, 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  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\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 overflow-hidden\"\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  // Width is set instantly from the measurer; the parent's single `layout`\n  // animation smooths the resize (text + icons together) so nothing competes.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\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    </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 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} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence 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 initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\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"}]}