{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A BorderBeam component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n/** Corner geometry: a pixel radius, or an Apple-style continuous squircle. */\nexport type BorderBeamRadius = number | \"squircle\";\n\nexport interface BorderBeamProps {\n  /** Keep the beams travelling. Set to `false` to freeze and hide them. */\n  active?: boolean;\n  /** How many beams ride the border, evenly phase-offset around the loop. */\n  beams?: number;\n  /** Thickness of the border ring in pixels. */\n  borderWidth?: number;\n  children?: ReactNode;\n  className?: string;\n  /** Trailing colour of the beam gradient. Use a light, saturated colour. */\n  colorFrom?: string;\n  /** Leading colour of the beam gradient. Use a light, saturated colour. */\n  colorTo?: string;\n  /** Seconds to wait before the first lap. */\n  delay?: number;\n  /** Seconds for one full lap of the border. */\n  duration?: number;\n  /** Pause the beams while a fine pointer hovers the element. */\n  pauseOnHover?: boolean;\n  /** Corner geometry of both the ring and the travel path. */\n  radius?: BorderBeamRadius;\n  /** Travel counter-clockwise. */\n  reverse?: boolean;\n  /** Arc length of each beam, in pixels measured along the border. */\n  size?: number;\n}\n\nconst DEFAULT_SIZE = 72;\nconst DEFAULT_DURATION = 6;\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_BEAMS = 1;\nconst DEFAULT_RADIUS = 16;\nconst DEFAULT_BORDER_WIDTH = 1;\n\nconst MS_PER_SECOND = 1000;\nconst HALF = 0.5;\nconst MIN_LAP_SECONDS = 0.1;\nconst OFFSET_PRECISION = 5;\n\n/** The comet is one dash on a path normalised to `pathLength={1}`. */\nconst PATH_UNIT = 1;\nconst MIN_COMET_FRACTION = 0.02;\nconst MAX_COMET_FRACTION = 0.45;\n\nconst CORE_WIDTH_FACTOR = 2.6;\nconst MIN_CORE_WIDTH = 2.5;\n\n/**\n * Four core dashes, not six, and one halo instead of two. Each dash is another\n * semi-opaque layer compositing over the last, so every extra one costs a paint\n * and buys less taper than the one before. Four is where the falloff stops\n * reading as steps: alpha lands on 0.14 / 0.48 / 0.82 / 0.98 towards the head.\n */\nconst CORE_LAYERS = 4;\n/** Shortest dash — the head — as a share of the full comet length. */\nconst CORE_MIN_LENGTH_RATIO = 0.1;\n/** >1 clusters the short dashes near the head, which is where the taper reads. */\nconst CORE_LENGTH_EXPONENT = 1.8;\nconst CORE_HEAD_OPACITY = 0.9;\nconst CORE_TAIL_OPACITY = 0.14;\n/** A beam of light barely thins along its trail; only the halo widens. */\nconst CORE_WIDTH_TAPER = 0.12;\n/** Just enough bloom to feather the seam between two stacked dashes. */\nconst CORE_BLUR_FACTOR = 0.2;\nconst MIN_CORE_BLUR = 0.5;\n\nconst HALO_WIDTH_FACTOR = 2.4;\nconst HALO_LENGTH_RATIO = 0.92;\nconst HALO_OPACITY = 0.34;\nconst HALO_BLUR_FACTOR = 0.7;\nconst MIN_HALO_BLUR = 4;\n/** How far each halo stop is pushed towards white. */\nconst HALO_TINT = \"68%\";\n\nconst RING_ACTIVE_OPACITY = 0.7;\nconst RING_STATIC_OPACITY = 1;\n\nconst SQUIRCLE_CORNER_RATIO = 0.3;\nconst SQUIRCLE_CONTROL_RATIO = 0.76;\n\ninterface Size {\n  height: number;\n  width: number;\n}\n\nconst EMPTY_SIZE: Size = { height: 0, width: 0 };\n\n/** One stroked dash of the comet stack: length, weight and alpha at its head. */\ninterface CometLayer {\n  blurred: boolean;\n  id: string;\n  length: number;\n  opacity: number;\n  width: number;\n}\n\nconst buildBorderPath = (\n  width: number,\n  height: number,\n  radius: BorderBeamRadius,\n  inset: number\n): string => {\n  const x = inset;\n  const y = inset;\n  const w = Math.max(0, width - inset * 2);\n  const h = Math.max(0, height - inset * 2);\n\n  if (radius === \"squircle\") {\n    const corner = Math.min(w, h) * SQUIRCLE_CORNER_RATIO;\n    const k = corner * SQUIRCLE_CONTROL_RATIO;\n    return [\n      `M ${x + corner} ${y}`,\n      `L ${x + w - corner} ${y}`,\n      `C ${x + w - corner + k} ${y} ${x + w} ${y + corner - k} ${x + w} ${y + corner}`,\n      `L ${x + w} ${y + h - corner}`,\n      `C ${x + w} ${y + h - corner + k} ${x + w - corner + k} ${y + h} ${x + w - corner} ${y + h}`,\n      `L ${x + corner} ${y + h}`,\n      `C ${x + corner - k} ${y + h} ${x} ${y + h - corner + k} ${x} ${y + h - corner}`,\n      `L ${x} ${y + corner}`,\n      `C ${x} ${y + corner - k} ${x + corner - k} ${y} ${x + corner} ${y}`,\n      \"Z\",\n    ].join(\" \");\n  }\n\n  const r = Math.max(0, Math.min(radius, Math.min(w, h) * HALF));\n  return [\n    `M ${x + r} ${y}`,\n    `H ${x + w - r}`,\n    `A ${r} ${r} 0 0 1 ${x + w} ${y + r}`,\n    `V ${y + h - r}`,\n    `A ${r} ${r} 0 0 1 ${x + w - r} ${y + h}`,\n    `H ${x + r}`,\n    `A ${r} ${r} 0 0 1 ${x} ${y + h - r}`,\n    `V ${y + r}`,\n    `A ${r} ${r} 0 0 1 ${x + r} ${y}`,\n    \"Z\",\n  ].join(\" \");\n};\n\n/** Wraps a normalised offset back into `[0, 1)` so the dash never falls off. */\nconst wrapUnit = (value: number) =>\n  ((value % PATH_UNIT) + PATH_UNIT) % PATH_UNIT;\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\n\n/**\n * Blurring a stroke darkens its fringe whenever the stroke itself is dark, so\n * the halo never inherits the beam colour verbatim — it is pushed towards white\n * first. A glow is light by definition; a dark blur is a shadow.\n */\nconst toHaloStop = (color: string) =>\n  `color-mix(in oklab, ${color} ${HALO_TINT}, white)`;\n\nconst BorderBeam = ({\n  active = true,\n  beams = DEFAULT_BEAMS,\n  borderWidth = DEFAULT_BORDER_WIDTH,\n  children,\n  className,\n  colorFrom = \"var(--color-brand)\",\n  colorTo = \"var(--color-brand-light)\",\n  delay = DEFAULT_DELAY,\n  duration = DEFAULT_DURATION,\n  pauseOnHover = false,\n  radius = DEFAULT_RADIUS,\n  reverse = false,\n  size = DEFAULT_SIZE,\n}: BorderBeamProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const rawId = useId();\n  const beamGradientId = `${rawId}-beam`;\n  const haloGradientId = `${rawId}-halo`;\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<SVGPathElement>(null);\n  const cometRef = useRef<SVGGElement>(null);\n  const dashRefs = useRef<(SVGPathElement | null)[]>([]);\n  const pausedRef = useRef(false);\n  const offscreenRef = useRef(false);\n  const [{ height, width }, setSize] = useState<Size>(EMPTY_SIZE);\n  const [perimeter, setPerimeter] = useState(0);\n\n  const beamCount = Math.max(1, Math.round(beams));\n  const hasBox = width > 0 && height > 0;\n  const path = hasBox\n    ? buildBorderPath(width, height, radius, borderWidth * HALF)\n    : \"\";\n  const isTravelling = active && !shouldReduceMotion && hasBox && perimeter > 0;\n  const headWidth = Math.max(MIN_CORE_WIDTH, borderWidth * CORE_WIDTH_FACTOR);\n  const haloBlur = Math.max(MIN_HALO_BLUR, headWidth * HALO_BLUR_FACTOR);\n  const coreBlur = Math.max(MIN_CORE_BLUR, headWidth * CORE_BLUR_FACTOR);\n\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!wrapper) {\n      return;\n    }\n    const measure = () => {\n      const rect = wrapper.getBoundingClientRect();\n      setSize({ height: rect.height, width: rect.width });\n    };\n    const observer = new ResizeObserver(measure);\n    measure();\n    observer.observe(wrapper);\n    return () => observer.disconnect();\n  }, []);\n\n  // The comet's arc length is authored in pixels, so it needs the real\n  // perimeter of the current outline before it can be normalised to path units.\n  useEffect(() => {\n    const ring = ringRef.current;\n    setPerimeter(ring && path ? ring.getTotalLength() : 0);\n  }, [path]);\n\n  const layers = useMemo<CometLayer[]>(() => {\n    if (perimeter <= 0) {\n      return [];\n    }\n    const comet = clamp(\n      size / perimeter,\n      MIN_COMET_FRACTION,\n      MAX_COMET_FRACTION\n    );\n    const lastIndex = CORE_LAYERS - 1;\n    // Every core dash ends at the same head position, so the exposed far end of\n    // the longest one reads as the faint tip of the tail and the overlap at the\n    // head accumulates into the brightest point.\n    const core = Array.from({ length: CORE_LAYERS }, (_unused, index) => {\n      const ratio = index / lastIndex;\n      const growth =\n        CORE_MIN_LENGTH_RATIO +\n        (1 - CORE_MIN_LENGTH_RATIO) * ratio ** CORE_LENGTH_EXPONENT;\n      return {\n        blurred: false,\n        id: `core-${index.toString()}`,\n        length: comet * growth,\n        opacity:\n          CORE_HEAD_OPACITY + (CORE_TAIL_OPACITY - CORE_HEAD_OPACITY) * ratio,\n        width: headWidth * (1 - CORE_WIDTH_TAPER * ratio),\n      };\n    });\n\n    return [\n      {\n        blurred: true,\n        id: \"halo\",\n        length: comet * HALO_LENGTH_RATIO,\n        opacity: HALO_OPACITY,\n        width: headWidth * HALO_WIDTH_FACTOR,\n      },\n      ...core,\n    ];\n  }, [headWidth, perimeter, size]);\n\n  // The comet is the outline itself: every layer is a dash stroked along the\n  // same `d`, so it is curved by construction and cannot leave the border. Only\n  // `stroke-dashoffset` moves, which slides the dash through corners instead of\n  // pivoting a straight sprite across them.\n  useEffect(() => {\n    if (!(isTravelling && layers.length > 0)) {\n      return;\n    }\n    const layerCount = layers.length;\n    const plan: { length: number; node: SVGPathElement; phase: number }[] = [];\n    for (let index = 0; index < beamCount * layerCount; index++) {\n      const node = dashRefs.current[index];\n      if (node) {\n        plan.push({\n          length: layers[index % layerCount].length,\n          node,\n          phase: Math.floor(index / layerCount) / beamCount,\n        });\n      }\n    }\n    if (plan.length === 0) {\n      return;\n    }\n\n    const lapSeconds = Math.max(MIN_LAP_SECONDS, duration);\n    let frameId = 0;\n    let lastTimestamp = performance.now();\n    let elapsed = 0;\n\n    const place = (progress: number) => {\n      for (const item of plan) {\n        const lap = wrapUnit(progress + item.phase);\n        // Travelling backwards puts the leading end at the dash start, so the\n        // tail keeps trailing the head in the direction of motion.\n        const head = reverse ? PATH_UNIT - lap : lap;\n        const offset = reverse ? -head : item.length - head;\n        item.node.style.strokeDashoffset =\n          wrapUnit(offset).toFixed(OFFSET_PRECISION);\n      }\n      const comet = cometRef.current;\n      if (comet) {\n        comet.style.opacity = \"1\";\n      }\n    };\n\n    const tick = (timestamp: number) => {\n      frameId = requestAnimationFrame(tick);\n      const deltaSeconds = (timestamp - lastTimestamp) / MS_PER_SECOND;\n      lastTimestamp = timestamp;\n      if (pausedRef.current || offscreenRef.current) {\n        return;\n      }\n      elapsed += deltaSeconds;\n      const progress = (elapsed - delay) / lapSeconds;\n      if (progress < 0) {\n        return;\n      }\n      place(progress);\n    };\n\n    frameId = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frameId);\n  }, [beamCount, delay, duration, isTravelling, layers, reverse]);\n\n  // Beams scrolled out of view keep no frame budget: the loop still runs but\n  // skips every style write until the card is visible again.\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!wrapper) {\n      return;\n    }\n    const observer = new IntersectionObserver((entries) => {\n      for (const entry of entries) {\n        offscreenRef.current = !entry.isIntersecting;\n      }\n    });\n    observer.observe(wrapper);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!(wrapper && pauseOnHover && isTravelling)) {\n      return;\n    }\n    const hoverQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    if (!hoverQuery.matches) {\n      return;\n    }\n\n    const pause = () => {\n      pausedRef.current = true;\n    };\n    const resume = () => {\n      pausedRef.current = false;\n    };\n\n    wrapper.addEventListener(\"pointerenter\", pause);\n    wrapper.addEventListener(\"pointerleave\", resume);\n    return () => {\n      pausedRef.current = false;\n      wrapper.removeEventListener(\"pointerenter\", pause);\n      wrapper.removeEventListener(\"pointerleave\", resume);\n    };\n  }, [isTravelling, pauseOnHover]);\n\n  const beamIds = Array.from(\n    { length: beamCount },\n    (_unused, index) => `beam-${index.toString()}`\n  );\n\n  const renderDash = (\n    layer: CometLayer,\n    layerIndex: number,\n    beamId: string,\n    beamIndex: number\n  ) => (\n    <path\n      d={path}\n      fill=\"none\"\n      key={`${beamId}-${layer.id}`}\n      pathLength={PATH_UNIT}\n      ref={(node) => {\n        dashRefs.current[beamIndex * layers.length + layerIndex] = node;\n      }}\n      stroke={`url(#${layer.blurred ? haloGradientId : beamGradientId})`}\n      strokeDasharray={`${layer.length} ${PATH_UNIT - layer.length}`}\n      strokeDashoffset={layer.length}\n      strokeLinecap=\"round\"\n      strokeOpacity={layer.opacity}\n      strokeWidth={layer.width}\n    />\n  );\n\n  return (\n    <div\n      className={cn(\"relative isolate\", className)}\n      ref={wrapperRef}\n      style={\n        radius === \"squircle\" ? undefined : { borderRadius: `${radius}px` }\n      }\n    >\n      {children}\n\n      {hasBox ? (\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 z-10 h-full w-full overflow-visible\"\n          fill=\"none\"\n          viewBox={`0 0 ${width} ${height}`}\n        >\n          <defs>\n            <linearGradient id={beamGradientId} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"1\">\n              <stop offset=\"0%\" stopColor={colorFrom} />\n              <stop offset=\"100%\" stopColor={colorTo} />\n            </linearGradient>\n            <linearGradient id={haloGradientId} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"1\">\n              <stop offset=\"0%\" stopColor={toHaloStop(colorFrom)} />\n              <stop offset=\"100%\" stopColor={toHaloStop(colorTo)} />\n            </linearGradient>\n          </defs>\n\n          {/* The ring is a hairline border, not part of the beam: keeping it off\n              the beam gradient means `colorFrom`/`colorTo` can be as bright as a\n              glow needs to be without turning the resting border into a stripe. */}\n          <path\n            d={path}\n            opacity={isTravelling ? RING_ACTIVE_OPACITY : RING_STATIC_OPACITY}\n            ref={ringRef}\n            stroke=\"var(--color-border)\"\n            strokeWidth={borderWidth}\n          />\n\n          {isTravelling ? (\n            <g ref={cometRef} style={{ opacity: 0 }}>\n              {/* `plus-lighter` only where there is darkness to add light to.\n                  On a light surface an additive glow washes out to nothing, so\n                  the halo composites normally there and simply tints. */}\n              <g\n                className=\"dark:mix-blend-plus-lighter\"\n                colorInterpolationFilters=\"sRGB\"\n                style={{ filter: `blur(${haloBlur}px)` }}\n              >\n                {beamIds.map((beamId, beamIndex) =>\n                  layers.map((layer, layerIndex) =>\n                    layer.blurred\n                      ? renderDash(layer, layerIndex, beamId, beamIndex)\n                      : null\n                  )\n                )}\n              </g>\n              <g\n                colorInterpolationFilters=\"sRGB\"\n                style={{ filter: `blur(${coreBlur}px)` }}\n              >\n                {beamIds.map((beamId, beamIndex) =>\n                  layers.map((layer, layerIndex) =>\n                    layer.blurred\n                      ? null\n                      : renderDash(layer, layerIndex, beamId, beamIndex)\n                  )\n                )}\n              </g>\n            </g>\n          ) : null}\n        </svg>\n      ) : null}\n    </div>\n  );\n};\n\nexport default BorderBeam;\n","path":"index.tsx","target":"components/smoothui/border-beam/index.tsx","type":"registry:ui"}],"name":"border-beam","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Border Beam","type":"registry:ui"}