{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A reading-progress indicator with bar, ring, number and segmented variants.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type MotionValue,\n  motion,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type CSSProperties, type RefObject, useState } from \"react\";\n\nconst SPRING_CONFIG = { damping: 30, mass: 0.4, stiffness: 220 } as const;\nconst SEGMENT_SPRING = { bounce: 0.1, duration: 0.25 } as const;\nconst DEFAULT_RING_SIZE_PX = 56;\nconst DEFAULT_SEGMENTS = 8;\nconst SEGMENT_WIDTH_PX = 18;\nconst PERCENT_MULTIPLIER = 100;\nconst MIN_PERCENT = 0;\nconst MAX_PERCENT = 100;\nconst DEFAULT_THICKNESS = 4;\nconst TRACK_MIN_THICKNESS = 2;\n/** Width of the brightened leading edge baked into the bar's own gradient. */\nconst LEADING_EDGE_STOP = \"2.25rem\";\n/** Ring label font size as a fraction of the ring diameter. */\nconst RING_LABEL_SIZE_RATIO = 0.28;\n/**\n * Digits carry no descender, so a numeral block centred on its line box reads\n * a hair low against a geometric centre like a ring. Nudging it up by a\n * fraction of an em restores the optical centre. Kept in em so it tracks the\n * figure size at any ring diameter.\n */\nconst NUMERAL_OPTICAL_LIFT = \"-0.028em\";\n\nexport type ScrollProgressVariant = \"bar\" | \"ring\" | \"number\" | \"segments\";\nexport type ScrollProgressPosition =\n  | \"top\"\n  | \"bottom\"\n  | \"fixed-corner\"\n  | \"inline\";\n\nexport interface ScrollProgressProps {\n  className?: string;\n  color?: string;\n  /** Ref to a scrollable ancestor whose own scroll is measured instead of the window. */\n  container?: RefObject<HTMLElement | null>;\n  /**\n   * Uses `position: sticky` (not `fixed`) so the indicator stays contained\n   * within its scrollable ancestor instead of escaping to the viewport.\n   * `inline` opts out of stickiness entirely — use it when you place the\n   * indicator in your own header or footer bar.\n   */\n  position?: ScrollProgressPosition;\n  /** Number of segments for the \"segments\" variant. */\n  segments?: number;\n  showLabel?: boolean;\n  /** Diameter (px) of the \"ring\" variant. */\n  size?: number;\n  /** Spring-smooth the value. Ignored (always direct) when reduced motion is preferred. */\n  smooth?: boolean;\n  /** Bar height, ring/segment stroke width, in pixels. */\n  thickness?: number;\n  variant?: ScrollProgressVariant;\n}\n\nconst POSITION_CLASSES: Record<ScrollProgressPosition, string> = {\n  bottom: \"sticky inset-x-0 bottom-0 z-40\",\n  \"fixed-corner\": \"sticky inset-x-0 bottom-4 z-40 flex justify-end pr-4\",\n  inline: \"relative\",\n  top: \"sticky inset-x-0 top-0 z-40\",\n};\n\nconst clampPercent = (value: number) =>\n  Math.min(MAX_PERCENT, Math.max(MIN_PERCENT, value));\n\nconst clampUnit = (value: number) => Math.min(1, Math.max(0, value));\n\ninterface PercentFigureProps {\n  className?: string;\n  /** Applied to the digits only, so the unit can stay muted beside them. */\n  digitColor?: string;\n  percent: number;\n  style?: CSSProperties;\n}\n\n/**\n * The digits and the `%` are one typographic unit, not two stacked spans:\n * shared baseline, tabular figures so the block never reflows as it counts,\n * and a unit sized as a deliberate fraction of the figures rather than an\n * arbitrary small size.\n */\nconst PercentFigure = ({\n  percent,\n  digitColor,\n  className,\n  style,\n}: PercentFigureProps) => (\n  <span\n    className={cn(\n      \"inline-flex items-baseline font-semibold leading-none tracking-tight\",\n      className\n    )}\n    style={style}\n  >\n    <span\n      className=\"tabular-nums\"\n      style={digitColor ? { color: digitColor } : undefined}\n    >\n      {percent}\n    </span>\n    <span className=\"ml-[0.06em] font-medium text-[0.56em] text-muted-foreground leading-none\">\n      %\n    </span>\n  </span>\n);\n\ninterface SegmentDashProps {\n  color: string;\n  index: number;\n  segments: number;\n  thickness: number;\n  value: MotionValue<number>;\n}\n\nconst SegmentDash = ({\n  value,\n  index,\n  segments,\n  thickness,\n  color,\n}: SegmentDashProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  // Each pill owns one 1/segments slice of the range, so they fill strictly\n  // one after another instead of all easing together.\n  const fill = useTransform(value, (progress) =>\n    clampUnit(progress * segments - index)\n  );\n  const springFill = useSpring(fill, SEGMENT_SPRING);\n\n  return (\n    <span\n      className=\"relative block shrink-0 overflow-hidden rounded-full bg-foreground/12\"\n      style={{ height: thickness, width: SEGMENT_WIDTH_PX }}\n    >\n      <motion.span\n        className=\"absolute inset-0 origin-left rounded-full\"\n        style={{\n          backgroundColor: color,\n          scaleX: shouldReduceMotion ? fill : springFill,\n        }}\n      />\n    </span>\n  );\n};\n\nexport default function ScrollProgress({\n  variant = \"bar\",\n  position = \"top\",\n  container,\n  thickness = DEFAULT_THICKNESS,\n  color = \"var(--color-brand)\",\n  showLabel = false,\n  segments = DEFAULT_SEGMENTS,\n  size = DEFAULT_RING_SIZE_PX,\n  smooth = true,\n  className,\n}: ScrollProgressProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const { scrollYProgress } = useScroll({ container });\n  const springValue = useSpring(scrollYProgress, SPRING_CONFIG);\n  const value = shouldReduceMotion || !smooth ? scrollYProgress : springValue;\n\n  const [percent, setPercent] = useState(0);\n  useMotionValueEvent(value, \"change\", (latest) => {\n    setPercent(Math.round(clampPercent(latest * PERCENT_MULTIPLIER)));\n  });\n\n  // The fill is full-width and slides in from the left instead of being\n  // scaled: a translate leaves the gradient and the rounded cap undistorted,\n  // so the leading edge is literally the end of the bar and can never drift\n  // away from it the way a separately positioned glow could.\n  const fillX = useTransform(\n    value,\n    (latest) => `${-PERCENT_MULTIPLIER * (1 - clampUnit(latest))}%`\n  );\n\n  const ariaProps = {\n    \"aria-label\": \"Reading progress\",\n    \"aria-valuemax\": MAX_PERCENT,\n    \"aria-valuemin\": MIN_PERCENT,\n    \"aria-valuenow\": percent,\n    role: \"progressbar\" as const,\n  };\n\n  if (variant === \"bar\") {\n    return (\n      <div\n        {...ariaProps}\n        className={cn(POSITION_CLASSES[position], \"w-full\", className)}\n      >\n        <div\n          className=\"w-full overflow-hidden rounded-full bg-foreground/12\"\n          style={{ height: Math.max(thickness, TRACK_MIN_THICKNESS) }}\n        >\n          <motion.div\n            className=\"h-full w-full rounded-full\"\n            style={{\n              backgroundImage: `linear-gradient(90deg, ${color} 0%, ${color} calc(100% - ${LEADING_EDGE_STOP}), color-mix(in oklab, ${color} 40%, white) 100%)`,\n              x: fillX,\n            }}\n          />\n        </div>\n        {showLabel ? (\n          <span className=\"sr-only\">{`${percent}% read`}</span>\n        ) : null}\n      </div>\n    );\n  }\n\n  if (variant === \"ring\") {\n    const ringRadius = (size - thickness) / 2;\n\n    return (\n      <div\n        {...ariaProps}\n        className={cn(\n          POSITION_CLASSES[position],\n          \"inline-flex shrink-0 items-center justify-center\",\n          className\n        )}\n      >\n        <svg\n          aria-hidden=\"true\"\n          className=\"-rotate-90\"\n          height={size}\n          viewBox={`0 0 ${size} ${size}`}\n          width={size}\n        >\n          <circle\n            className=\"text-foreground/12\"\n            cx={size / 2}\n            cy={size / 2}\n            fill=\"none\"\n            r={ringRadius}\n            stroke=\"currentColor\"\n            strokeWidth={thickness}\n          />\n          <motion.circle\n            cx={size / 2}\n            cy={size / 2}\n            fill=\"none\"\n            pathLength={1}\n            r={ringRadius}\n            stroke={color}\n            strokeLinecap=\"round\"\n            strokeWidth={thickness}\n            style={{ pathLength: value }}\n          />\n        </svg>\n        {showLabel ? (\n          // The figure block is centred as one unit inside the ring, then\n          // lifted by the optical correction — geometric centring alone leaves\n          // a descender-less numeral sitting low.\n          <span className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n            <PercentFigure\n              className=\"text-foreground\"\n              percent={percent}\n              style={{\n                // `em` here resolves against the figure's own size, so the\n                // correction scales with the ring.\n                fontSize: size * RING_LABEL_SIZE_RATIO,\n                transform: `translateY(${NUMERAL_OPTICAL_LIFT})`,\n              }}\n            />\n          </span>\n        ) : null}\n      </div>\n    );\n  }\n\n  if (variant === \"number\") {\n    return (\n      <div\n        {...ariaProps}\n        className={cn(\n          POSITION_CLASSES[position],\n          // Size lives on the root so a consumer's `text-*` class wins over it\n          // and scales the figures with it.\n          \"inline-flex shrink-0 items-baseline text-4xl leading-none\",\n          className\n        )}\n      >\n        <PercentFigure digitColor={color} percent={percent} />\n        {showLabel ? (\n          // Sits further from the figure than the `%` does, so the unit reads\n          // as part of the number and the word reads as a separate label.\n          <span className=\"ml-[0.9em] font-medium text-[0.3em] text-muted-foreground uppercase leading-none tracking-[0.08em]\">\n            read\n          </span>\n        ) : null}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      {...ariaProps}\n      className={cn(\n        POSITION_CLASSES[position],\n        \"flex items-center gap-1.5\",\n        className\n      )}\n    >\n      {Array.from({ length: segments }, (_, index) => (\n        <SegmentDash\n          color={color}\n          index={index}\n          key={`scroll-progress-segment-${index}`}\n          segments={segments}\n          thickness={thickness}\n          value={value}\n        />\n      ))}\n      {showLabel ? (\n        // Fixed measure + tabular figures: the row must not shuffle sideways\n        // every time the count gains a digit.\n        <span className=\"ml-2 w-[3.25ch] text-right font-medium text-muted-foreground text-xs tabular-nums\">\n          {percent}%\n        </span>\n      ) : null}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/scroll-progress/index.tsx","type":"registry:ui"}],"name":"scroll-progress","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Scroll Progress","type":"registry:ui"}