{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"card-folder","type":"registry:block","title":"Card Folder","description":"A landscape card tucked into a stitched purse pocket that lifts forward as the purse compresses into its bottom seam, with controlled open and card-detail visibility plus a separate overflow action.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/card-folder.tsx","type":"registry:component","target":"@components/motion/card-folder.tsx","content":"\"use client\";\n// beui.dev/components/blocks/card-folder\n\nimport { EllipsisVertical, Eye, EyeOff } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { type ReactNode, useCallback, useEffect, useState } from \"react\";\nimport { DigitSwap } from \"@/components/motion/digit-swap\";\nimport {\n  EASE_IN_OUT,\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_PRESS,\n} from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// The two purse layers collapse as one material surface after the card starts\n// moving, then reverse immediately so closing feels like the card is caught.\nconst PURSE_MORPH_TRANSITION = {\n  duration: 0.28,\n  ease: EASE_IN_OUT,\n} as const;\nconst PURSE_REDUCED_TRANSITION = {\n  duration: 0.16,\n  ease: EASE_OUT,\n} as const;\n\nexport interface CardFolderProps {\n  title: string;\n  cardNumber: string;\n  expiry: string;\n  cvv: string;\n  card: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  detailsVisible?: boolean;\n  defaultDetailsVisible?: boolean;\n  onDetailsVisibleChange?: (visible: boolean) => void;\n  onClick?: () => void;\n  onAction?: () => void;\n  ariaLabel?: string;\n  actionLabel?: string;\n  disabled?: boolean;\n  className?: string;\n  cardClassName?: string;\n}\n\n/**\n * A landscape card tucked into an animated folder sleeve. Pressing the folder\n * lifts the card forward while the purse compresses into its bottom seam; a\n * separate privacy control reveals its number and CVV.\n */\nexport function CardFolder({\n  title,\n  cardNumber,\n  expiry,\n  cvv,\n  card,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  detailsVisible,\n  defaultDetailsVisible = false,\n  onDetailsVisibleChange,\n  onClick,\n  onAction,\n  ariaLabel,\n  actionLabel,\n  disabled = false,\n  className,\n  cardClassName,\n}: CardFolderProps) {\n  const reduce = useReducedMotion();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internalDetailsVisible, setInternalDetailsVisible] = useState(\n    defaultDetailsVisible,\n  );\n  const openControlled = open !== undefined;\n  const detailsControlled = detailsVisible !== undefined;\n  const isOpen = open ?? internalOpen;\n  const areDetailsVisible = detailsVisible ?? internalDetailsVisible;\n  const transition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n  const normalizedCardNumber = cardNumber.replace(/\\D/g, \"\");\n  const visibleLastFour = normalizedCardNumber.slice(-4).padStart(4, \"•\");\n  const revealedCardNumber =\n    normalizedCardNumber.match(/.{1,4}/g)?.join(\" \") ?? visibleLastFour;\n  const maskedCvv = \"•\".repeat(Math.max(3, cvv.length));\n  const defaultAriaLabel = `${isOpen ? \"Close\" : \"Open\"} ${title}, card ending in ${visibleLastFour}, expires ${expiry}`;\n  const progress = useMotionValue(isOpen ? 1 : 0);\n  const cardTransform = useTransform(progress, (value) => {\n    const boundedProgress = Math.min(1, Math.max(0, value));\n    const lift = Math.sin(Math.PI * boundedProgress);\n    return `translateY(${-8 * lift}%) scale(${1 + 0.01 * lift})`;\n  });\n  const backTransform = useTransform(\n    progress,\n    [0, 1],\n    [\"translateY(0%) scaleY(1)\", \"translateY(18%) scaleY(0.18)\"],\n  );\n  const frontTransform = useTransform(\n    progress,\n    [0, 1],\n    [\"translateY(0%) rotateX(0deg)\", \"translateY(18%) rotateX(-72deg)\"],\n  );\n  const purseOpacity = useTransform(progress, [0, 0.76, 1], [1, 1, 0]);\n\n  useEffect(() => {\n    const controls = animate(\n      progress,\n      isOpen ? 1 : 0,\n      reduce ? { duration: 0 } : PURSE_MORPH_TRANSITION,\n    );\n    return () => controls.stop();\n  }, [isOpen, progress, reduce]);\n\n  const setOpen = useCallback(\n    (nextOpen: boolean) => {\n      if (disabled) return;\n      if (!openControlled) setInternalOpen(nextOpen);\n      onOpenChange?.(nextOpen);\n    },\n    [disabled, onOpenChange, openControlled],\n  );\n\n  const handleClick = () => {\n    setOpen(!isOpen);\n    onClick?.();\n  };\n\n  const toggleDetails = () => {\n    if (disabled) return;\n    const nextVisible = !areDetailsVisible;\n    if (!detailsControlled) setInternalDetailsVisible(nextVisible);\n    onDetailsVisibleChange?.(nextVisible);\n  };\n\n  return (\n    <div\n      data-open={isOpen ? \"true\" : \"false\"}\n      data-details-visible={areDetailsVisible ? \"true\" : \"false\"}\n      className={cn(\n        \"relative aspect-[1029/592] w-96 max-w-full select-none [perspective:1200px]\",\n        className,\n      )}\n    >\n      <motion.button\n        type=\"button\"\n        disabled={disabled}\n        aria-label={ariaLabel ?? defaultAriaLabel}\n        aria-expanded={isOpen}\n        onClick={handleClick}\n        whileTap={reduce || disabled ? undefined : { scale: 0.96 }}\n        transition={reduce ? { duration: 0 } : SPRING_PRESS}\n        className=\"absolute inset-0 block rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50\"\n      >\n        <motion.span\n          data-slot=\"card-folder-back\"\n          aria-hidden=\"true\"\n          initial={false}\n          animate={reduce ? { opacity: isOpen ? 0 : 1 } : undefined}\n          transition={PURSE_REDUCED_TRANSITION}\n          style={\n            reduce\n              ? undefined\n              : { opacity: purseOpacity, transform: backTransform }\n          }\n          className=\"absolute inset-x-0 bottom-0 top-[10%] rounded-2xl border border-foreground/10 bg-background [transform-origin:center_bottom]\"\n        />\n\n        <motion.span\n          aria-hidden=\"true\"\n          initial={false}\n          animate={\n            reduce ? { transform: \"translateY(0%) scale(1)\" } : undefined\n          }\n          style={reduce ? undefined : { transform: cardTransform }}\n          className={cn(\n            \"absolute left-[4.7%] right-[4.7%] top-0 z-10 aspect-[1.586/1] overflow-hidden rounded-xl border border-foreground/10 bg-background [transform-origin:center_bottom] will-change-transform\",\n            cardClassName,\n          )}\n        >\n          {card}\n        </motion.span>\n      </motion.button>\n\n      <motion.span\n        aria-hidden={isOpen}\n        inert={isOpen}\n        initial={false}\n        animate={reduce ? { opacity: isOpen ? 0 : 1 } : undefined}\n        transition={PURSE_REDUCED_TRANSITION}\n        style={\n          reduce\n            ? undefined\n            : { opacity: purseOpacity, transform: frontTransform }\n        }\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 top-1/2 z-20 [backface-visibility:hidden] [transform-origin:center_bottom]\"\n      >\n          <svg\n            aria-hidden=\"true\"\n            viewBox=\"0 0 384 110\"\n            preserveAspectRatio=\"none\"\n            className=\"absolute inset-0 size-full overflow-visible\"\n          >\n            <path\n              d=\"M0 17C15 7 31 4 49 4H87C110 4 126 17 144 32L158 44C176 59 206 59 225 43L240 30C257 16 271 4 295 4H335C354 4 370 8 384 18V94C384 103 377 110 368 110H16C7 110 0 103 0 94Z\"\n              fill=\"var(--background)\"\n              stroke=\"var(--foreground)\"\n              strokeOpacity=\"0.12\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <path\n              d=\"M10 21C22 13 35 11 51 11H85C105 11 120 23 137 37L153 50C175 68 208 68 231 49L246 36C262 23 275 11 297 11H333C350 11 363 14 374 22V89C374 97 369 101 360 101H24C15 101 10 96 10 89Z\"\n              fill=\"none\"\n              stroke=\"var(--foreground)\"\n              strokeDasharray=\"5 5\"\n              strokeLinecap=\"round\"\n              strokeOpacity=\"0.22\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </svg>\n\n          <span className=\"absolute inset-x-[5%] inset-y-0 z-10 flex min-w-0 flex-col justify-between py-4\">\n            <span className=\"flex items-start justify-between pr-2\">\n              <motion.button\n                key=\"card-details-visibility\"\n                type=\"button\"\n                disabled={disabled}\n                tabIndex={isOpen ? -1 : undefined}\n                aria-label={\n                  areDetailsVisible\n                    ? \"Hide card details\"\n                    : \"Show card details\"\n                }\n                aria-pressed={areDetailsVisible}\n                onClick={toggleDetails}\n                whileTap={reduce || disabled ? undefined : { scale: 0.96 }}\n                transition={reduce ? { duration: 0.12 } : SPRING_PRESS}\n                className={cn(\n                  \"z-30 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n                  isOpen ? \"pointer-events-none\" : \"pointer-events-auto\",\n                )}\n              >\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={areDetailsVisible ? \"hide\" : \"show\"}\n                    initial={\n                      reduce\n                        ? { opacity: 0 }\n                        : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }\n                    }\n                    animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                    exit={\n                      reduce\n                        ? { opacity: 0 }\n                        : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }\n                    }\n                    transition={\n                      reduce\n                        ? { duration: 0.12 }\n                        : { type: \"spring\", duration: 0.3, bounce: 0 }\n                    }\n                    className=\"flex items-center justify-center\"\n                  >\n                    {areDetailsVisible ? (\n                      <EyeOff className=\"size-4\" aria-hidden=\"true\" />\n                    ) : (\n                      <Eye className=\"size-4\" aria-hidden=\"true\" />\n                    )}\n                  </motion.span>\n                </AnimatePresence>\n              </motion.button>\n\n              <span className=\"flex shrink-0 items-end gap-3.5 pt-2\">\n                <span className=\"flex flex-col gap-0.5\">\n                  <span className=\"text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/65\">\n                    Expiry\n                  </span>\n                  <span className=\"text-xs font-medium text-foreground tabular-nums\">\n                    {expiry}\n                  </span>\n                </span>\n                <span className=\"flex flex-col gap-0.5\">\n                  <span className=\"text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/65\">\n                    CVV\n                  </span>\n                  <DigitSwap\n                    value={areDetailsVisible ? cvv : maskedCvv}\n                    animationKey={\n                      areDetailsVisible ? \"revealed\" : \"masked\"\n                    }\n                    direction={areDetailsVisible ? \"up\" : \"down\"}\n                    className=\"text-xs font-medium text-foreground tabular-nums\"\n                  />\n                </span>\n              </span>\n            </span>\n            <span className=\"flex min-w-0 items-baseline justify-between gap-4\">\n              <span className=\"truncate text-lg font-medium leading-tight text-foreground\">\n                {title}\n              </span>\n              <DigitSwap\n                value={\n                  areDetailsVisible\n                    ? revealedCardNumber\n                    : `•••• •••• •••• ${visibleLastFour}`\n                }\n                animationKey={areDetailsVisible ? \"revealed\" : \"masked\"}\n                direction={areDetailsVisible ? \"up\" : \"down\"}\n                suffixLength={4}\n                glyphClassName={\n                  areDetailsVisible\n                    ? \"text-foreground\"\n                    : \"text-muted-foreground\"\n                }\n                suffixClassName=\"text-foreground\"\n                className=\"truncate font-mono text-xs tracking-[0.08em] tabular-nums\"\n              />\n            </span>\n          </span>\n      </motion.span>\n\n      {onAction ? (\n        <motion.button\n          type=\"button\"\n          disabled={disabled}\n          aria-label={actionLabel ?? `Open actions for ${title}`}\n          onClick={onAction}\n          animate={{ y: isOpen && !reduce ? -14 : 0 }}\n          whileTap={reduce || disabled ? undefined : { scale: 0.96 }}\n          transition={transition}\n          className=\"absolute right-[2.6%] top-[10%] z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full text-white/65 outline-none transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white disabled:cursor-not-allowed disabled:opacity-50\"\n        >\n          <EllipsisVertical className=\"size-4\" aria-hidden=\"true\" />\n        </motion.button>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/digit-swap.tsx","type":"registry:component","target":"@components/motion/digit-swap.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DigitSwapDirection = \"up\" | \"down\";\n\nexport interface DigitSwapProps {\n  /** Numeric or masked value rendered in fixed character slots. */\n  value: string | number;\n  /** Replays every glyph when the value itself contains unchanged characters. */\n  animationKey?: string | number;\n  /** Direction the next glyph enters from. */\n  direction?: DigitSwapDirection;\n  /** Per-glyph transition duration in seconds. */\n  duration?: number;\n  /** Delay in seconds between neighboring glyphs. */\n  stagger?: number;\n  /** Number of final characters that receive `suffixClassName`. */\n  suffixLength?: number;\n  className?: string;\n  glyphClassName?: string;\n  suffixClassName?: string;\n}\n\ntype GlyphMotionContext = {\n  direction: DigitSwapDirection;\n  reduceMotion: boolean;\n};\n\nconst GLYPH_VARIANTS = {\n  enter: ({ direction, reduceMotion }: GlyphMotionContext) => ({\n    opacity: 0,\n    transform: reduceMotion\n      ? \"none\"\n      : `translateY(${direction === \"up\" ? \"45%\" : \"-45%\"})`,\n  }),\n  visible: {\n    opacity: 1,\n    transform: \"translateY(0%)\",\n  },\n  exit: ({ direction, reduceMotion }: GlyphMotionContext) => ({\n    opacity: 0,\n    transform: reduceMotion\n      ? \"none\"\n      : `translateY(${direction === \"up\" ? \"-45%\" : \"45%\"})`,\n  }),\n};\n\n/** Fixed-slot digits and mask glyphs that roll when their value changes. */\nexport function DigitSwap({\n  value,\n  animationKey,\n  direction = \"up\",\n  duration = 0.18,\n  stagger = 0.006,\n  suffixLength = 0,\n  className,\n  glyphClassName,\n  suffixClassName,\n}: DigitSwapProps) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const text = String(value);\n  const suffixStart = Math.max(0, text.length - Math.max(0, suffixLength));\n  const motionContext: GlyphMotionContext = { direction, reduceMotion };\n  const glyphs = Array.from(text, (character, position) => ({\n    character,\n    id: `glyph-${position}`,\n    position,\n  }));\n\n  return (\n    <span\n      data-slot=\"digit-swap\"\n      data-direction={direction}\n      className={cn(\"inline-flex items-center whitespace-nowrap\", className)}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {glyphs.map(({ character, id, position }) => {\n          if (character === \" \") {\n            return <span key={id} className=\"inline-block w-[0.7ch]\" />;\n          }\n\n          const glyphKey =\n            animationKey === undefined\n              ? `${id}-${character}`\n              : `${id}-${character}-${animationKey}`;\n\n          return (\n            <span\n              key={id}\n              data-slot=\"digit-swap-glyph\"\n              className=\"relative inline-block h-[1.1em] w-[1ch] shrink-0 overflow-hidden align-bottom\"\n            >\n              <AnimatePresence initial={false} custom={motionContext}>\n                <motion.span\n                  key={glyphKey}\n                  custom={motionContext}\n                  variants={GLYPH_VARIANTS}\n                  initial=\"enter\"\n                  animate=\"visible\"\n                  exit=\"exit\"\n                  transition={{\n                    duration: reduceMotion ? Math.min(duration, 0.12) : duration,\n                    delay: reduceMotion ? 0 : position * stagger,\n                    ease: EASE_OUT,\n                  }}\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center leading-none\",\n                    glyphClassName,\n                    position >= suffixStart ? suffixClassName : undefined,\n                  )}\n                >\n                  {character}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","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\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","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"}]}