{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meniscus",
  "type": "registry:ui",
  "title": "Meniscus",
  "description": "A scroll-progress bar that reads as liquid filling a channel — a cool blue-to-white gradient with a white leading bead that glows while scrolling and settles to a solid neutral fill at 100%.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/loaders/meniscus/meniscus.tsx",
      "type": "registry:ui",
      "target": "components/crucible/meniscus.tsx",
      "content": "\"use client\";\n\nimport { motion, type MotionStyle, useMotionValueEvent, useScroll, useSpring, useTransform } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface MeniscusProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /**\n   * Track the scroll progress of this element instead of the page. Pass a\n   * ref to an `overflow-y-auto` container to drive the bar from an internal\n   * scroll region — mirrors the `scroller` prop on Runnel.\n   */\n  scroller?: React.RefObject<HTMLElement | null>;\n  /** Fill gradient, tail to leading edge. @default [\"#4c6a92\", \"#dbe9ff\"] */\n  colors?: [string, string];\n  /** Channel + fill thickness in px. @default 4 */\n  height?: number;\n  /** Accessible label. @default \"Scroll progress\" */\n  label?: string;\n}\n\n/**\n * Meniscus — a scroll-progress bar rendered as liquid filling a channel:\n * a cool blue-to-white gradient fill with a white leading bead that\n * glows brighter while actively scrolling and calms when idle, flashing once\n * and settling to a solid neutral fill at 100%. Reports progress via\n * `role=\"progressbar\"`.\n *\n * Renders as a plain block-level element — position it yourself (e.g.\n * `className=\"fixed inset-x-0 top-0 z-50\"` for a page-top bar, or\n * `className=\"absolute inset-x-0 top-0\"` alongside a `scroller` container).\n * Falls back to a static, non-glowing fill under reduced motion.\n */\nexport function Meniscus({\n  scroller,\n  colors = [\"#4c6a92\", \"#dbe9ff\"],\n  height = 4,\n  label = \"Scroll progress\",\n  className,\n  style,\n  ...props\n}: MeniscusProps) {\n  const reducedMotion = useReducedMotion();\n  const fillRef = React.useRef<HTMLDivElement>(null);\n  const [valueNow, setValueNow] = React.useState(0);\n  const lastPercentRef = React.useRef(0);\n  const idleTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const completedRef = React.useRef(false);\n\n  const { scrollYProgress } = useScroll(scroller ? { container: scroller } : undefined);\n  const smoothProgress = useSpring(scrollYProgress, {\n    stiffness: reducedMotion ? 1000 : 300,\n    damping: reducedMotion ? 100 : 40,\n    restDelta: 0.001,\n  });\n  const width = useTransform(smoothProgress, (v) => `${Math.max(0, Math.min(1, v)) * 100}%`);\n  // The meniscus edge (wave + white-hot bead) rides the fill's right end — hide\n  // it until there is actually a fill, so the rest state reads fully cold.\n  const edgeOpacity = useTransform(smoothProgress, [0, 0.02], [0, 1]);\n\n  useMotionValueEvent(smoothProgress, \"change\", (latest) => {\n    const clamped = Math.max(0, Math.min(1, latest));\n    const percent = Math.round(clamped * 100);\n\n    if (percent !== lastPercentRef.current) {\n      lastPercentRef.current = percent;\n      setValueNow(percent);\n    }\n\n    const node = fillRef.current;\n    if (!node) return;\n\n    if (!reducedMotion) {\n      node.dataset.active = \"true\";\n      window.clearTimeout(idleTimerRef.current);\n      idleTimerRef.current = setTimeout(() => {\n        if (fillRef.current) fillRef.current.dataset.active = \"false\";\n      }, 200);\n    }\n\n    const isComplete = clamped >= 0.995;\n    if (isComplete && !completedRef.current) {\n      completedRef.current = true;\n      node.dataset.complete = \"true\";\n    } else if (!isComplete && completedRef.current) {\n      completedRef.current = false;\n      node.dataset.complete = \"false\";\n    }\n  });\n\n  React.useEffect(() => () => window.clearTimeout(idleTimerRef.current), []);\n\n  return (\n    <div\n      data-crucible=\"meniscus\"\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={valueNow}\n      className={cn(\"relative w-full overflow-visible rounded-full bg-black/40\", className)}\n      style={{ height, ...style }}\n      {...props}\n    >\n      <style>{`\n        [data-crucible=\"meniscus\"] .meniscus-fill {\n          transition: filter 200ms ease-out;\n          filter: drop-shadow(0 0 2px var(--meniscus-hot)) brightness(0.85);\n        }\n        [data-crucible=\"meniscus\"] .meniscus-fill[data-active=\"true\"] {\n          filter: drop-shadow(0 0 8px var(--meniscus-hot)) brightness(1.15);\n        }\n        [data-crucible=\"meniscus\"] .meniscus-fill[data-complete=\"true\"] {\n          animation: crucible-meniscus-temper 700ms ease-out forwards;\n        }\n        @keyframes crucible-meniscus-temper {\n          0% { filter: drop-shadow(0 0 14px #ffffff) brightness(1.6); }\n          60% { filter: drop-shadow(0 0 10px var(--meniscus-hot)) brightness(1.3); }\n          100% { filter: drop-shadow(0 0 4px rgba(255,255,255,0.35)) brightness(1.05) saturate(0.35); }\n        }\n        [data-crucible=\"meniscus\"] .meniscus-wave {\n          animation: crucible-meniscus-wave 1.6s ease-in-out infinite;\n        }\n        @keyframes crucible-meniscus-wave {\n          0%, 100% { transform: translateY(-1px) scaleY(1); }\n          50% { transform: translateY(1px) scaleY(1.15); }\n        }\n        @media (prefers-reduced-motion: reduce) {\n          [data-crucible=\"meniscus\"] .meniscus-fill,\n          [data-crucible=\"meniscus\"] .meniscus-fill[data-active=\"true\"],\n          [data-crucible=\"meniscus\"] .meniscus-fill[data-complete=\"true\"] {\n            filter: none;\n            animation: none;\n          }\n          [data-crucible=\"meniscus\"] .meniscus-wave {\n            animation: none;\n          }\n        }\n      `}</style>\n      <motion.div\n        ref={fillRef}\n        className=\"meniscus-fill relative h-full overflow-visible rounded-full\"\n        style={\n          {\n            width,\n            background: `linear-gradient(90deg, ${colors[0]}, ${colors[1]})`,\n            \"--meniscus-hot\": colors[1],\n          } as MotionStyle\n        }\n      >\n        {!reducedMotion && (\n          <motion.svg\n            aria-hidden\n            className=\"meniscus-wave absolute right-0 -translate-y-1/2 translate-x-1/2 overflow-visible\"\n            style={{ top: \"50%\", width: height * 4, height: height * 4, opacity: edgeOpacity }}\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n          >\n            <path\n              d=\"M0 12 Q6 5 12 12 T24 12\"\n              stroke={colors[1]}\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n            />\n          </motion.svg>\n        )}\n        {!reducedMotion && (\n          <motion.span\n            aria-hidden\n            className=\"absolute top-1/2 right-0 -translate-y-1/2 translate-x-1/2 rounded-full bg-white\"\n            style={{\n              width: height * 2,\n              height: height * 2,\n              boxShadow: `0 0 ${height * 3}px ${height}px ${colors[1]}`,\n              opacity: edgeOpacity,\n            }}\n          />\n        )}\n      </motion.div>\n    </div>\n  );\n}\n"
    }
  ]
}