{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"folder","type":"registry:component","title":"Folder","description":"A project tile that holds your actual files: it opens on a hinge, fans real sheets, and takes a dropped file with weight.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/folder.tsx","type":"registry:component","content":"// Hairline UI · folder · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { INTERACTION_FOCUS, createSpring, useInteractionReducedMotion, type Spring } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FOLDER: a project tile that holds your actual files\n *\n *   hover    the front leans open on its hinge and the sheets fan\n *            out behind it, leaning toward the mouse; each sheet\n *            has its own weight, so they never move in step\n *   open     the sheets rise far enough to read and to click\n *   drop     drag a file over and the folder gapes; let go and a\n *            sheet with that file's name falls in, the folder takes\n *            the weight and the count rolls up. If your upload\n *            fails it pops back out and the folder says so\n *   glass    the front is the one pane of glass: what's behind it\n *            really is behind it\n *\n * Everything runs on springs, so moving the mouse in and out\n * mid-flight just curves on from where each sheet is.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FolderFile {\n  id: string;\n  name: string;\n  /** A picture for the sheet. Without one it draws lines of text under a coloured band. */\n  image?: string;\n  /** The band's colour, any CSS colour. Defaults to the chart colours in turn. */\n  tint?: string;\n}\n\nexport interface FolderHandle {\n  /** Add files as a drop would: the same fall, the same onDropFiles. */\n  addFiles: (files: File[]) => void;\n}\n\nexport interface FolderProps {\n  name: string;\n  /** Everything in the folder; the last three are the sheets on show. */\n  files: FolderFile[];\n  /** Total shown on the front, if more exists than you pass in `files`. */\n  count?: number;\n  /** \"files\" unless you hold something else. */\n  noun?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Called when a raised sheet is clicked. Without it, sheets are only to look at. */\n  onFileOpen?: (file: FolderFile) => void;\n  /**\n   * Turns on dropping. Upload here: the sheet falls in straight away, and is removed again when this resolves, so add\n   * the real file to `files` by then. Throw to send it back out.\n   */\n  onDropFiles?: (files: File[]) => Promise<unknown> | unknown;\n  /** The folder's colour, any CSS colour. Defaults to var(--primary). */\n  color?: string;\n  /** Text on the front. Defaults to var(--primary-foreground). */\n  foreground?: string;\n  /** Width in px; everything scales from it. */\n  size?: number;\n  className?: string;\n  ref?: React.Ref<FolderHandle>;\n}\n\nconst WIDTH = 240;\nconst ROOM = 76; // above the folder, for the sheets to rise into\nconst BODY = 176;\nconst SHEET_W = 112;\nconst SHEET_H = 144;\nconst SHOWN = 3;\nconst BACK = \"M0 14a14 14 0 0 1 14-14h82c5 0 8 2 11 6l5 6c2 2 4 2 6 2h108a14 14 0 0 1 14 14v124a24 24 0 0 1-24 24H24a24 24 0 0 1-24-24Z\";\n\ntype Mood = \"rest\" | \"hover\" | \"open\" | \"over\";\ntype Pose = { x: number; y: number; r: number };\n\n// Front sheet first. Parked is the one just pushed off the back: it sinks behind the front and fades.\nconst POSES: Record<Mood, [Pose, Pose, Pose]> = {\n  rest: [{ x: 2, y: 12, r: -1.5 }, { x: 30, y: 4, r: 6 }, { x: -32, y: 7, r: -7 }],\n  hover: [{ x: -2, y: -8, r: -2 }, { x: 46, y: -20, r: 10 }, { x: -46, y: -14, r: -11 }],\n  open: [{ x: 0, y: -66, r: -2 }, { x: 72, y: -46, r: 12 }, { x: -72, y: -52, r: -13 }],\n  over: [{ x: 0, y: -2, r: 0 }, { x: 62, y: -14, r: 13 }, { x: -62, y: -14, r: -13 }],\n};\nconst PARKED: Pose = { x: 0, y: 16, r: 0 };\nconst ANGLE: Record<Mood, number> = { rest: 7, hover: 21, open: 31, over: 40 };\n\n// The front sheet is light and quick, the back one heavy and late: the fan comes from that, not from delays.\nconst WEIGHTS = [\n  { stiffness: 210, damping: 16 },\n  { stiffness: 150, damping: 14 },\n  { stiffness: 115, damping: 13 },\n];\nconst HINGE = { stiffness: 170, damping: 15 };\nconst GIVE = { stiffness: 320, damping: 12 };\nconst SHAKE = { stiffness: 900, damping: 16 };\nconst LEAN = [11, 7, 5];\nconst LENS = 620;\nconst FRONT_H = BODY - 34;\n\nconst CHARTS = [\"var(--chart-1)\", \"var(--chart-2)\", \"var(--chart-3)\", \"var(--chart-5)\"];\nconst COUNT_FORMAT: Intl.NumberFormatOptions = { maximumFractionDigits: 0 };\n\nconst LINE_WIDTHS = [\"92%\", \"78%\", \"86%\", \"64%\", \"90%\", \"72%\"];\n\nfunction hashTint(id: string) {\n  let n = 0;\n  for (const char of id) n = (n + char.charCodeAt(0)) % CHARTS.length;\n  return CHARTS[n];\n}\n\nconst Sheet = React.memo(function Sheet({ file, actionable, onOpen }: { file: FolderFile; actionable: boolean; onOpen?: (file: FolderFile) => void }) {\n  const inner = (\n    <>\n      {/* The name goes first: it's the part that shows over the folder's edge. */}\n      <div className=\"truncate px-2.5 pb-2 pt-2.5 text-left text-[10.5px] font-medium leading-none text-card-foreground\">{file.name}</div>\n      {file.image ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={file.image} alt=\"\" draggable={false} className=\"mx-2.5 h-[104px] w-[calc(100%_-_20px)] rounded-[6px] object-cover\" />\n      ) : (\n        <div className=\"px-2.5\">\n          <div className=\"h-[22px] rounded-[5px]\" style={{ background: file.tint ?? hashTint(file.id) }} />\n          <div className=\"mt-2 grid gap-[5px]\">\n            {LINE_WIDTHS.map((width) => (\n              <div key={width} className=\"h-[3.5px] rounded-full bg-foreground/[0.13]\" style={{ width }} />\n            ))}\n          </div>\n        </div>\n      )}\n    </>\n  );\n  const base = \"relative block size-full overflow-hidden rounded-[12px]\";\n  return actionable ? (\n    <button type=\"button\" onClick={() => onOpen?.(file)} className={`${base} cursor-pointer ${INTERACTION_FOCUS}`} aria-label={`Open ${file.name}`}>\n      {inner}\n    </button>\n  ) : (\n    <div className={base}>{inner}</div>\n  );\n});\n\ntype Entry = { node: HTMLElement; x: Spring; y: Spring; r: Spring; falling: boolean };\n\nexport function Folder({ name, files, count, noun = \"files\", open, defaultOpen = false, onOpenChange, onFileOpen, onDropFiles, color = \"var(--primary)\", foreground = \"var(--primary-foreground)\", size = WIDTH, className = \"\", ref }: FolderProps) {\n  const reduced = useInteractionReducedMotion();\n  const [ownOpen, setOwnOpen] = React.useState(defaultOpen);\n  const isOpen = open ?? ownOpen;\n  const [hovering, setHovering] = React.useState(false);\n  const [over, setOver] = React.useState(false);\n  const [added, setAdded] = React.useState<(FolderFile & { url?: string; leaving?: boolean })[]>([]);\n  const [note, setNote] = React.useState<string | null>(null);\n  const nextId = React.useRef(0);\n\n  const mood: Mood = over ? \"over\" : isOpen ? \"open\" : hovering ? \"hover\" : \"rest\";\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const flapRef = React.useRef<HTMLDivElement>(null);\n  const glassRef = React.useRef<HTMLDivElement>(null);\n  const nodes = React.useRef(new Map<string, HTMLElement>());\n  const entries = React.useRef(new Map<string, Entry>());\n  const falling = React.useRef(new Set<string>());\n  const moodRef = React.useRef(mood);\n  const leanRef = React.useRef(0);\n  const whole = React.useRef<{ hinge: Spring; give: Spring; shake: Spring } | null>(null);\n\n  const visibleAdded = added.filter((a) => !files.some((f) => f.id === a.id));\n  const all = React.useMemo(() => [...files, ...added.filter((a) => !files.some((f) => f.id === a.id))], [files, added]);\n  const settled = all.filter((f) => !(\"leaving\" in f && f.leaving));\n  // One extra stays mounted behind the front, so the sheet pushed off the back doesn't vanish in view.\n  const shown = all.slice(-(SHOWN + 1));\n  const total = (count ?? files.length) + visibleAdded.filter((a) => !a.leaving).length;\n\n  const paint = React.useCallback(() => {\n    const w = whole.current;\n    if (!w) return;\n    const give = w.give.get();\n    if (bodyRef.current) bodyRef.current.style.transform = `translate3d(${w.shake.get().toFixed(2)}px,0,0) scale(${(1 + give * 0.035).toFixed(4)},${(1 - give * 0.05).toFixed(4)})`;\n    const angle = w.hinge.get();\n    if (flapRef.current) flapRef.current.style.transform = `perspective(${LENS}px) rotateX(${(-angle).toFixed(2)}deg)`;\n    // Browsers blur unreliably through a 3D layer, so the blur is a flat pane under the tilted front, kept the height the\n    // front appears to be. Its top edge is what the lens does to the front's top edge.\n    if (glassRef.current) {\n      const radians = (angle * Math.PI) / 180;\n      const seen = (Math.cos(radians) * LENS) / (LENS - FRONT_H * Math.sin(radians));\n      glassRef.current.style.transform = `scaleY(${seen.toFixed(4)})`;\n    }\n    entries.current.forEach((entry) => {\n      const y = entry.y.get();\n      entry.node.style.transform = `translate3d(${entry.x.get().toFixed(2)}px,${y.toFixed(2)}px,0) rotate(${entry.r.get().toFixed(2)}deg)`;\n      // A sheet reaching the bottom of its fall hits the folder: it stops there with a small bounce, and hands over its weight.\n      if (entry.falling && entry.y.getVelocity() > 0 && y > entry.y.getTarget() - 6) {\n        entry.falling = false;\n        const speed = entry.y.getVelocity();\n        w.give.push(Math.min(9, speed * 0.016));\n        entry.y.jump(entry.y.getTarget());\n        entry.y.push(-speed * 0.1);\n      }\n    });\n  }, []);\n\n  const poseOf = React.useCallback((depth: number, m: Mood): Pose => (depth >= SHOWN ? PARKED : POSES[m][depth]), []);\n\n  /** Aim every sheet at where its depth and the mood say, plus the lean toward the mouse. */\n  const aim = React.useCallback(() => {\n    const ids = [...nodes.current.keys()];\n    entries.current.forEach((entry, id) => {\n      const depth = ids.length - 1 - ids.indexOf(id);\n      const pose = poseOf(depth, moodRef.current);\n      const lean = moodRef.current === \"hover\" ? leanRef.current * (LEAN[depth] ?? 0) : 0;\n      entry.x.set(pose.x + lean);\n      entry.y.set(pose.y);\n      entry.r.set(pose.r + lean * 0.25);\n    });\n  }, [poseOf]);\n\n  React.useLayoutEffect(() => {\n    const s = { hinge: createSpring(ANGLE[moodRef.current], HINGE, paint, 0.02), give: createSpring(0, GIVE, paint, 0.002), shake: createSpring(0, SHAKE, paint, 0.02) };\n    whole.current = s;\n    paint();\n    const sheets = entries.current;\n    return () => {\n      s.hinge.stop();\n      s.give.stop();\n      s.shake.stop();\n      whole.current = null;\n      sheets.forEach((entry) => {\n        entry.x.stop();\n        entry.y.stop();\n        entry.r.stop();\n      });\n      sheets.clear();\n    };\n  }, [paint]);\n\n  // Sheets joining and leaving: nodes are in DOM order, oldest first, so a sheet's depth is its distance from the end.\n  const shownKey = shown.map((f) => f.id).join(\"\\n\");\n  React.useLayoutEffect(() => {\n    const ids = shownKey ? shownKey.split(\"\\n\") : [];\n    moodRef.current = mood;\n    entries.current.forEach((entry, id) => {\n      if (ids.includes(id)) return;\n      entry.x.stop();\n      entry.y.stop();\n      entry.r.stop();\n      entries.current.delete(id);\n    });\n    const ordered = new Map<string, HTMLElement>();\n    ids.forEach((id, index) => {\n      const node = nodes.current.get(id);\n      if (!node) return;\n      ordered.set(id, node);\n      if (entries.current.has(id)) return;\n      const arrives = falling.current.delete(id) && !reduced;\n      const depth = ids.length - 1 - index;\n      const rest = poseOf(depth, mood);\n      const start = arrives ? { x: rest.x * 0.3, y: -ROOM - SHEET_H * 0.5, r: 7 } : rest;\n      const weight = WEIGHTS[Math.min(depth, WEIGHTS.length - 1)];\n      entries.current.set(id, { node, x: createSpring(start.x, weight, paint, 0.02), y: createSpring(start.y, weight, paint, 0.02), r: createSpring(start.r, weight, paint, 0.02), falling: arrives });\n    });\n    nodes.current = ordered;\n    if (reduced) {\n      const list = [...ordered.keys()];\n      entries.current.forEach((entry, id) => {\n        const pose = poseOf(list.length - 1 - list.indexOf(id), mood);\n        entry.x.jump(pose.x);\n        entry.y.jump(pose.y);\n        entry.r.jump(pose.r);\n      });\n      whole.current?.hinge.jump(ANGLE[mood]);\n    } else {\n      aim();\n      whole.current?.hinge.set(ANGLE[mood]);\n    }\n    paint();\n  }, [shownKey, mood, reduced, aim, paint, poseOf]);\n\n  const setOpenState = React.useCallback(\n    (next: boolean) => {\n      if (open === undefined) setOwnOpen(next);\n      onOpenChange?.(next);\n    },\n    [open, onOpenChange],\n  );\n\n  // Open stays open until Escape or a press elsewhere, so a phone can read it too.\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const onDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpenState(false);\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpenState(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [isOpen, setOpenState]);\n\n  const noteTimer = React.useRef(0);\n  React.useEffect(() => () => window.clearTimeout(noteTimer.current), []);\n\n  const receive = React.useCallback(\n    async (dropped: File[]) => {\n      if (!dropped.length) return;\n      const items = dropped.map((file) => {\n        const id = `incoming-${nextId.current++}`;\n        falling.current.add(id);\n        return { id, name: file.name, url: file.type.startsWith(\"image/\") ? URL.createObjectURL(file) : undefined };\n      });\n      const sheets = items.map((item) => ({ id: item.id, name: item.name, image: item.url, url: item.url }));\n      setAdded((list) => [...list, ...sheets]);\n      setNote(dropped.length > 1 ? `Adding ${dropped.length}` : \"Adding\");\n      const release = () => {\n        setAdded((list) => list.filter((a) => !items.some((i) => i.id === a.id)));\n        items.forEach((item) => item.url && URL.revokeObjectURL(item.url));\n      };\n      try {\n        await onDropFiles?.(dropped);\n        release();\n        setNote(null);\n      } catch {\n        // Back out the way it came, with a shake that says no.\n        setAdded((list) => list.map((a) => (items.some((i) => i.id === a.id) ? { ...a, leaving: true } : a)));\n        items.forEach((item) => {\n          const entry = entries.current.get(item.id);\n          entry?.y.set(-ROOM - SHEET_H);\n          if (entry) entry.falling = false;\n        });\n        whole.current?.shake.push(reduced ? 0 : 240);\n        setNote(dropped.length > 1 ? `Couldn't add ${dropped.length} files` : `Couldn't add ${dropped[0].name}`);\n        window.clearTimeout(noteTimer.current);\n        noteTimer.current = window.setTimeout(() => setNote(null), 2600);\n        window.setTimeout(release, reduced ? 0 : 520);\n      }\n    },\n    [onDropFiles, reduced],\n  );\n\n  React.useImperativeHandle(ref, () => ({ addFiles: (list: File[]) => void receive(list) }), [receive]);\n\n  const droppable = Boolean(onDropFiles);\n  const dropProps = droppable\n    ? {\n        onDragEnter: (event: React.DragEvent) => {\n          if (event.dataTransfer.types.includes(\"Files\")) setOver(true);\n        },\n        onDragOver: (event: React.DragEvent) => {\n          if (!event.dataTransfer.types.includes(\"Files\")) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n        },\n        onDragLeave: (event: React.DragEvent) => {\n          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOver(false);\n        },\n        onDrop: (event: React.DragEvent) => {\n          event.preventDefault();\n          setOver(false);\n          void receive(Array.from(event.dataTransfer.files));\n        },\n      }\n    : {};\n\n  const scale = size / WIDTH;\n  const message = over ? \"Drop to add\" : note;\n  const front = `color-mix(in oklab, ${color} 88%, transparent)`;\n  const seen = settled.slice(-SHOWN).map((f) => f.id);\n\n  return (\n    <div\n      ref={rootRef}\n      {...dropProps}\n      className={`relative inline-block select-none ${className}`}\n      style={{ width: size, height: (ROOM + BODY) * scale }}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") setHovering(true);\n      }}\n      onPointerLeave={() => {\n        setHovering(false);\n        leanRef.current = 0;\n        aim();\n      }}\n      onPointerMove={(event) => {\n        if (event.pointerType !== \"mouse\" || reduced) return;\n        const box = event.currentTarget.getBoundingClientRect();\n        leanRef.current = Math.max(-1, Math.min(1, ((event.clientX - box.left) / box.width - 0.5) * 2));\n        aim();\n      }}\n    >\n      <div className=\"absolute left-0 top-0 origin-top-left\" style={{ width: WIDTH, height: ROOM + BODY, transform: scale === 1 ? undefined : `scale(${scale})` }}>\n        <div ref={bodyRef} className=\"absolute inset-x-0 bottom-0 origin-bottom will-change-transform\" style={{ height: BODY }}>\n          {/* The back: deeper than the front, with a tab, shaded where the sheets go in. */}\n          <svg aria-hidden=\"true\" viewBox={`0 0 ${WIDTH} ${BODY}`} className=\"absolute inset-0 size-full overflow-visible\" style={{ filter: \"drop-shadow(0 14px 14px rgb(0 0 0 / 0.16))\" }}>\n            <path\n              d={BACK}\n              style={{ fill: `color-mix(in oklab, ${color} 66%, black)` }}\n            />\n          </svg>\n\n          <div className=\"absolute\" style={{ left: (WIDTH - SHEET_W) / 2, top: 12, width: SHEET_W, height: SHEET_H }}>\n            {shown.map((file, index) => {\n              const raised = isOpen && seen.includes(file.id);\n              const gone = (\"leaving\" in file && file.leaving) || shown.length - 1 - index >= SHOWN;\n              return (\n                <div\n                  key={file.id}\n                  ref={(node) => {\n                    if (node) {\n                      if (!nodes.current.has(file.id)) nodes.current.set(file.id, node);\n                    } else nodes.current.delete(file.id);\n                  }}\n                  inert={!raised}\n                  className=\"absolute inset-0 origin-bottom rounded-[12px] bg-card will-change-transform\"\n                  style={{ boxShadow: \"0 0 0 0.5px rgb(0 0 0 / 0.1), 0 1px 2px rgb(0 0 0 / 0.14), 0 5px 12px -3px rgb(0 0 0 / 0.2)\", opacity: gone ? 0 : 1, transition: \"opacity 320ms ease-out 120ms\" }}\n                >\n                  <Sheet file={file} actionable={Boolean(onFileOpen)} onOpen={onFileOpen} />\n                </div>\n              );\n            })}\n          </div>\n\n          {/* The one pane of glass: the sheets really are behind it. */}\n          <div ref={glassRef} aria-hidden=\"true\" className=\"absolute inset-x-0 bottom-0 origin-bottom rounded-[22px] will-change-transform [-webkit-backdrop-filter:blur(7px)] [backdrop-filter:blur(7px)]\" style={{ height: FRONT_H }} />\n\n          {/* The front, hinged along its bottom edge, tinted over the glass. */}\n          <div\n            ref={flapRef}\n            className=\"absolute inset-x-0 bottom-0 origin-bottom overflow-hidden rounded-[22px] will-change-transform\"\n            style={{\n              height: FRONT_H,\n              background: `linear-gradient(to bottom, color-mix(in oklab, ${front} 78%, white), ${front} 42%)`,\n              boxShadow: \"inset 0 1px 0 rgb(255 255 255 / 0.42), inset 0 -1px 0 rgb(0 0 0 / 0.1), 0 -1px 3px rgb(0 0 0 / 0.1)\",\n              color: foreground,\n            }}\n          >\n            <button\n              type=\"button\"\n              aria-expanded={isOpen}\n              aria-label={`${name}, ${total} ${noun}`}\n              onClick={() => setOpenState(!isOpen)}\n              onPointerDown={() => !reduced && whole.current?.give.set(0.55)}\n              onPointerUp={() => whole.current?.give.set(0)}\n              onPointerCancel={() => whole.current?.give.set(0)}\n              onPointerLeave={() => whole.current?.give.set(0)}\n              onFocus={(event) => {\n                if (event.currentTarget.matches(\":focus-visible\")) setHovering(true);\n              }}\n              onBlur={() => setHovering(false)}\n              className=\"absolute inset-0 z-10 cursor-pointer rounded-[inherit] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-current\"\n            />\n            <div className=\"pointer-events-none absolute inset-x-4 bottom-3.5\">\n              <div className=\"truncate text-[15px] font-semibold leading-tight tracking-[-0.01em]\">{name}</div>\n              <div className=\"relative mt-1 h-[16px] text-[12px] font-medium leading-[16px] opacity-80\">\n                <span className=\"absolute inset-x-0 top-0 flex gap-1 whitespace-nowrap transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none\" style={{ opacity: message ? 0 : 1, transform: message ? \"translateY(-6px)\" : \"none\" }}>\n                  <NumberRoll value={total} format={COUNT_FORMAT} duration={620} />\n                  <span>{total === 1 ? noun.replace(/s$/, \"\") : noun}</span>\n                </span>\n                <span className=\"absolute inset-x-0 top-0 truncate transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none\" style={{ opacity: message ? 1 : 0, transform: message ? \"none\" : \"translateY(6px)\" }}>\n                  {message}\n                </span>\n              </div>\n            </div>\n          </div>\n\n        </div>\n      </div>\n\n      <span role=\"status\" className=\"sr-only\">\n        {note ?? \"\"}\n      </span>\n    </div>\n  );\n}\n\nexport default Folder;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · folder · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** A raised object: one tight contact shadow, one short ambient, a hairline of light on its top edge. */\nexport const MATERIAL_LIFT =\n  'shadow-[0_1px_1.5px_rgb(0_0_0/0.07),0_3px_8px_-2px_rgb(0_0_0/0.1),inset_0_0.5px_0_rgb(255_255_255/0.8)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_4px_10px_-2px_rgb(0_0_0/0.35),inset_0_0.5px_0_rgb(255_255_255/0.1)]';\n/** A trough something sits in, shaded from above, lit along its bottom edge. */\nexport const MATERIAL_RECESS =\n  'shadow-[inset_0_1px_2.5px_rgb(0_0_0/0.09),inset_0_-0.5px_0_rgb(255_255_255/0.7)] dark:shadow-[inset_0_1px_3px_rgb(0_0_0/0.45),inset_0_-0.5px_0_rgb(255_255_255/0.07)]';\n\nexport type SpringConfig = { stiffness: number; damping: number; mass?: number };\nexport interface Spring {\n  get(): number;\n  getVelocity(): number;\n  getTarget(): number;\n  /** Aim somewhere new from wherever it is now, keeping its speed. */\n  set(target: number): void;\n  /** Add speed (units per second), on top of what it already has. */\n  push(velocity: number): void;\n  /** Be there now, at rest. */\n  jump(value: number): void;\n  configure(config: SpringConfig): void;\n  stop(): void;\n}\n\n// Every moving spring on the page shares one frame loop, which sleeps when the last one rests. Each spring's onFrame runs\n// once per frame after all of them have stepped, so several springs that draw one shape paint it once.\nconst SPRING_STEP = 1 / 240;\ntype Running = { step: () => void };\nconst runningSprings = new Set<Running>();\nconst springPainters = new Set<() => void>();\nlet springFrame = 0;\nlet springLast = 0;\nlet springSpare = 0;\n\nfunction springTick(now: number) {\n  // A hidden tab stops the frames, so the first one back must not count the whole gap as time passed.\n  springSpare += Math.min((now - springLast) / 1000, 0.05);\n  springLast = now;\n  while (springSpare >= SPRING_STEP) {\n    runningSprings.forEach((spring) => spring.step());\n    springSpare -= SPRING_STEP;\n  }\n  const painters = [...springPainters];\n  springPainters.clear();\n  painters.forEach((paint) => paint());\n  springFrame = runningSprings.size ? requestAnimationFrame(springTick) : 0;\n}\n\nfunction wakeSprings(spring: Running) {\n  runningSprings.add(spring);\n  if (springFrame) return;\n  springSpare = 0;\n  springFrame = requestAnimationFrame((now) => {\n    springLast = now - 1000 / 60;\n    springTick(now);\n  });\n}\n\n/**\n * A spring that carries its speed: re-aim it mid-flight and it curves on from where it is, push it while it swings and the\n * push adds to the swing. Use it for anything that can be interrupted; a curve restarts from a standstill every time.\n * `onFrame` should write straight to the DOM. `precision` is how close counts as there, in the value's own units.\n */\nexport function createSpring(initial: number, config: SpringConfig, onFrame: () => void, precision = 0.01): Spring {\n  let value = initial;\n  let velocity = 0;\n  let target = initial;\n  let { stiffness, damping, mass = 1 } = config;\n  const running: Running = {\n    step() {\n      velocity += ((-stiffness * (value - target) - damping * velocity) / mass) * SPRING_STEP;\n      value += velocity * SPRING_STEP;\n      if (Math.abs(value - target) < precision && Math.abs(velocity) < precision * 10) {\n        value = target;\n        velocity = 0;\n        runningSprings.delete(running);\n      }\n      springPainters.add(onFrame);\n    },\n  };\n  return {\n    get: () => value,\n    getVelocity: () => velocity,\n    getTarget: () => target,\n    set(next) {\n      if (next === target && !runningSprings.has(running)) return;\n      target = next;\n      wakeSprings(running);\n    },\n    push(speed) {\n      velocity += speed;\n      wakeSprings(running);\n    },\n    jump(next) {\n      value = next;\n      target = next;\n      velocity = 0;\n      runningSprings.delete(running);\n      onFrame();\n    },\n    configure(next) {\n      ({ stiffness, damping, mass = 1 } = next);\n    },\n    stop() {\n      runningSprings.delete(running);\n    },\n  };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/folder. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}