{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-11",
  "title": "Glass bands",
  "description": "Concentric bands of one hue behind a headline, each starting its ramp again at its edge so the set reads as glass, its centre leaning towards the pointer.",
  "dependencies": [
    "not showing"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/background/background-11.tsx",
      "content": "'use client';\n\nimport { useEffect, useRef, type CSSProperties } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\n\nimport { watchAim, type Aim } from './_parts/aim';\nimport { DemoBand } from './_parts/demo-band';\nimport { DEFAULT_HUE, useHueCycle } from './_parts/hue';\nimport { clamp, towards } from './_parts/math';\n\nconst DEFAULT_BANDS = 6;\n\nconst MIN_BANDS = 5;\n\nconst MAX_BANDS = 7;\n\nconst DEFAULT_SURFACE = 'var(--background)';\n\n/**\n * The bands are ellipses of one shape, each a larger copy of the one inside it, so the edge of a\n * band runs alongside the edge of its neighbour all the way round. A size is a share of the box:\n * the innermost band sits behind a headline, the outermost one covers the box.\n */\nconst INNER_SCALE = 0.3;\n\nconst OUTER_SCALE = 1.45;\n\nconst HEIGHT_SHARE = 1.7;\n\n/** The innermost band has no neighbour inside it: its ramp starts this far out, in per cent. */\nconst FIRST_RAMP_START = 45;\n\n/**\n * A band is an opaque tint of the hue on the surface under the piece, so a band covers what lies\n * under it and the colour starts again at its edge. Over that tint, a ramp deepens it towards the\n * outer edge.\n */\nconst BAND_TINT =\n  'color-mix(in oklab, currentColor var(--glass-light), var(--glass-surface))';\n\nconst BAND_DEPTH =\n  'color-mix(in oklab, currentColor var(--glass-depth), transparent)';\n\n/** How much hue a band holds at its inner and outer edge, drawn stronger on a dark surface. */\nconst TINTS =\n  '[--glass-depth:13%] [--glass-light:4%] dark:[--glass-depth:20%] dark:[--glass-light:6%]';\n\n/**\n * How far the innermost band travels when the aim reaches an edge of the box, in hundredths of the\n * box for each hundredth of aim. The outer bands travel less, so the set leans like a stepped lens.\n */\nconst FOLLOW_ACROSS = 0.12;\n\nconst FOLLOW_DOWN = 0.2;\n\nconst OUTER_FOLLOW = 0.45;\n\nconst GLIDE = 'translate var(--glass-ms) var(--glass-ease)';\n\nconst WALK_EASE = 'cubic-bezier(0.45, 0, 0.55, 1)';\n\nconst FOLLOW_EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\n/** The centre moves, then rests: a step is longer than the glide that makes it. */\nconst WALK_STEP_MS = 4400;\n\nconst WALK_GLIDE_MS = 2600;\n\nconst FOLLOW_MS = 700;\n\n/** The round the centre walks while no pointer leads it. The first one is where it rests. */\nconst WAYPOINTS: Aim[] = [\n  { x: 0.5, y: 0.5 },\n  { x: 0.7, y: 0.36 },\n  { x: 0.4, y: 0.66 },\n  { x: 0.62, y: 0.62 },\n  { x: 0.3, y: 0.42 },\n];\n\ntype GlassStyle = CSSProperties & {\n  '--glass-x': number;\n  '--glass-y': number;\n  '--glass-ms': string;\n  '--glass-ease': string;\n  '--glass-surface': string;\n};\n\nfunction easeOf(isFollowing: boolean) {\n  if (isFollowing) {\n    return FOLLOW_EASE;\n  }\n  return WALK_EASE;\n}\n\n/** The aim, counted from the middle of the box in hundredths of it. */\nfunction offsetOf(share: number) {\n  const fromMiddle = share - 0.5;\n  return fromMiddle * 100;\n}\n\nfunction moveCentre(\n  host: HTMLElement,\n  aim: Aim,\n  durationMs: number,\n  isFollowing: boolean\n) {\n  const across = offsetOf(aim.x);\n  const down = offsetOf(aim.y);\n  host.style.setProperty('--glass-ms', `${durationMs}ms`);\n  host.style.setProperty('--glass-ease', easeOf(isFollowing));\n  host.style.setProperty('--glass-x', String(across));\n  host.style.setProperty('--glass-y', String(down));\n}\n\n/** Where the ramp of a band starts, in per cent of its radius: at the edge of the band inside it. */\nfunction rampStartOf(scale: number, step: number, isInnermost: boolean) {\n  if (isInnermost) {\n    return FIRST_RAMP_START;\n  }\n  const inside = scale - step;\n  const share = inside / scale;\n  return share * 100;\n}\n\nfunction bandStyleOf(index: number, count: number): CSSProperties {\n  const last = count - 1;\n  const share = index / last;\n  const span = OUTER_SCALE - INNER_SCALE;\n  const step = span / last;\n  const scale = towards(INNER_SCALE, OUTER_SCALE, share);\n  const follow = towards(1, OUTER_FOLLOW, share);\n  const followAcross = follow * FOLLOW_ACROSS;\n  const followDown = follow * FOLLOW_DOWN;\n  const width = scale * 100;\n  const height = width * HEIGHT_SHARE;\n  const rampStart = rampStartOf(scale, step, index === 0);\n  return {\n    width: `${width}%`,\n    height: `${height}%`,\n    backgroundColor: BAND_TINT,\n    backgroundImage: `radial-gradient(closest-side, transparent ${rampStart}%, ${BAND_DEPTH})`,\n    translate: `calc(var(--glass-x) * ${followAcross}cqw) calc(var(--glass-y) * ${followDown}cqh)`,\n    transition: GLIDE,\n  };\n}\n\n/** From the outermost band to the innermost, which is the order they are painted in. */\nfunction bandsOf(count: number) {\n  const indexes = Array.from({ length: count }, (_, index) => index);\n  return indexes.reverse();\n}\n\nexport type GlassBandsProps = {\n  className?: string;\n  /** Any colour token, such as `var(--primary)` or `var(--series-2)`. Every band is a tint of it. */\n  hue?: string;\n  /** How many bands, from five to seven. */\n  bands?: number;\n  /** The colour under the piece. A band is an opaque tint of the hue on it. */\n  surface?: string;\n  /** The centre of the bands eases towards the pointer. Without it, the centre only walks its round. */\n  interactive?: boolean;\n};\n\n/**\n * Concentric bands of one hue. Each holds its own short ramp, light at its inner edge and a little\n * deeper at its outer edge, so the colour starts again at every curved edge and the set reads as\n * glass. The centre eases towards the pointer and the inner bands travel further than the outer\n * ones. It is all CSS: a band moves by a transform, on a transition. It fills its parent, which\n * must be positioned. Under reduced motion the bands rest, centred.\n */\nexport function GlassBands({\n  className,\n  hue = DEFAULT_HUE,\n  bands = DEFAULT_BANDS,\n  surface = DEFAULT_SURFACE,\n  interactive = true,\n}: GlassBandsProps) {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const whole = Math.round(bands);\n  const count = clamp(whole, MIN_BANDS, MAX_BANDS);\n  const hostStyle: GlassStyle = {\n    color: hue,\n    '--glass-x': 0,\n    '--glass-y': 0,\n    '--glass-ms': '0ms',\n    '--glass-ease': WALK_EASE,\n    '--glass-surface': surface,\n  };\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host || shouldReduceMotion) {\n      return;\n    }\n    return watchAim(host, {\n      waypoints: WAYPOINTS,\n      stepMs: WALK_STEP_MS,\n      glideMs: WALK_GLIDE_MS,\n      followMs: FOLLOW_MS,\n      isInteractive: interactive,\n      onAim: (aim, durationMs, isFollowing) =>\n        moveCentre(host, aim, durationMs, isFollowing),\n    });\n  }, [interactive, shouldReduceMotion]);\n\n  return (\n    <div\n      ref={hostRef}\n      aria-hidden='true'\n      style={hostStyle}\n      className={cn(\n        '[container-type:size] pointer-events-none absolute inset-0 overflow-hidden transition-colors duration-500',\n        TINTS,\n        className\n      )}\n    >\n      {bandsOf(count).map((index) => (\n        <div\n          key={index}\n          style={bandStyleOf(index, count)}\n          className='absolute inset-0 m-auto rounded-full'\n        />\n      ))}\n    </div>\n  );\n}\n\nexport default function Background11() {\n  const { hue, nextHue } = useHueCycle();\n\n  return (\n    <DemoBand\n      title='One hue that starts again at every edge'\n      background={<GlassBands hue={hue} />}\n    >\n      <Button onClick={nextHue}>Change hue</Button>\n    </DemoBand>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/background-11.tsx"
    },
    {
      "path": "registry/ndeko/components/background/_parts/aim.ts",
      "content": "import { landTransitions } from './loops';\nimport { watchPointer } from './pointer';\nimport { watchShowing } from './showing';\n\n/** A place in a box: from 0 at its left or top edge to 1 at its right or bottom edge. */\nexport type Aim = {\n  x: number;\n  y: number;\n};\n\nexport type AimOptions = {\n  /** The round the aim walks while no pointer leads it. The first one is where it rests. */\n  waypoints: readonly Aim[];\n  /** Milliseconds between two waypoints. */\n  stepMs: number;\n  /** Milliseconds of the glide to a waypoint: `stepMs` for a walk without a rest, less for one with rests. */\n  glideMs: number;\n  /** Milliseconds for the aim to reach a pointer. */\n  followMs: number;\n  isInteractive: boolean;\n  /**\n   * Sends the picture to `aim` over `durationMs`. A CSS transition carries it there, so nothing is\n   * computed on the way and no frame is asked for.\n   */\n  onAim: (aim: Aim, durationMs: number, isFollowing: boolean) => void;\n};\n\nfunction idle() {}\n\n/**\n * An aim that walks from waypoint to waypoint while the host shows, and goes to the pointer while\n * one is over the host. One timer steps the walk. Hidden, the timer is gone and the glide in flight\n * lands at once where it was going. The function returned ends the watch.\n */\nexport function watchAim(host: HTMLElement, options: AimOptions) {\n  const { waypoints, stepMs, glideMs, followMs, isInteractive, onAim } =\n    options;\n  const first = waypoints[0];\n  if (!first) {\n    return idle;\n  }\n  let index = 0;\n  let latest = first;\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  let isShowing = false;\n  let stopPointer = idle;\n\n  function clearStep() {\n    clearTimeout(timer);\n    timer = undefined;\n  }\n\n  function stepOn() {\n    const next = index + 1;\n    index = next % waypoints.length;\n    latest = waypoints[index] ?? first;\n    onAim(latest, glideMs, false);\n    timer = setTimeout(stepOn, stepMs);\n  }\n\n  function follow(x: number, y: number) {\n    clearStep();\n    const across = x / host.offsetWidth;\n    const down = y / host.offsetHeight;\n    latest = { x: across, y: down };\n    onAim(latest, followMs, true);\n  }\n\n  function release() {\n    if (isShowing) {\n      stepOn();\n    }\n  }\n\n  function show() {\n    isShowing = true;\n    if (isInteractive) {\n      stopPointer = watchPointer(host, follow, release);\n    }\n    stepOn();\n  }\n\n  function hide() {\n    isShowing = false;\n    clearStep();\n    stopPointer();\n    stopPointer = idle;\n    onAim(latest, 0, false);\n    landTransitions(host);\n  }\n\n  const stopShowing = watchShowing(host, (isNowShowing) => {\n    if (isNowShowing) {\n      show();\n      return;\n    }\n    hide();\n  });\n\n  return () => {\n    stopShowing();\n    hide();\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/aim.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/demo-band.tsx",
      "content": "import { useId, type ReactNode, type Ref } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\n\ntype BandAlign = 'center' | 'end';\n\nconst ALIGNMENTS: Record<BandAlign, string> = {\n  center: 'justify-center',\n  end: 'justify-end pb-10',\n};\n\ntype DemoBandProps = {\n  ref?: Ref<HTMLElement>;\n  title: string;\n  /** The background under test. It fills the band, behind the headline and the actions. */\n  background: ReactNode;\n  /** `end` sets the headline at the foot of the band and leaves its middle to the background. */\n  align?: BandAlign;\n  children: ReactNode;\n};\n\n/**\n * The band every background is shown in: the page colour, a headline in the foreground colour and\n * the actions laid straight over the background, to prove that text stays readable above it.\n */\nexport function DemoBand({\n  ref,\n  title,\n  background,\n  align = 'center',\n  children,\n}: DemoBandProps) {\n  const titleId = useId();\n\n  return (\n    <section\n      ref={ref}\n      aria-labelledby={titleId}\n      className={cn(\n        'bg-background @container relative isolate flex h-90 w-full flex-col items-center overflow-hidden rounded-xl',\n        ALIGNMENTS[align]\n      )}\n    >\n      {background}\n      <div className='relative flex max-w-lg flex-col items-center gap-5 px-6 text-center'>\n        <h3\n          id={titleId}\n          className='text-2xl font-semibold text-balance @lg:text-3xl'\n        >\n          {title}\n        </h3>\n        <div className='flex flex-wrap items-center justify-center gap-2'>\n          {children}\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/demo-band.tsx"
    },
    {
      "path": "registry/ndeko/components/background/_parts/hue.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** A background wears one hue: the brand unless it is told otherwise. */\nexport const DEFAULT_HUE = 'var(--primary)';\n\n/** The brand first, then the series hues that differ from it. */\nconst DEMO_HUES = [\n  DEFAULT_HUE,\n  'var(--series-2)',\n  'var(--series-3)',\n  'var(--series-4)',\n  'var(--series-5)',\n];\n\n/** The hue of a demo, and the step to the next one, to show a background in each hue it accepts. */\nexport function useHueCycle() {\n  const [index, setIndex] = useState(0);\n  const hue = DEMO_HUES[index] ?? DEFAULT_HUE;\n\n  function nextHue() {\n    setIndex((current) => {\n      const next = current + 1;\n      return next % DEMO_HUES.length;\n    });\n  }\n\n  return { hue, nextHue };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/hue.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/loops.ts",
      "content": "import { watchShowing } from './showing';\n\nfunction hold(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.pause();\n  }\n}\n\nfunction resume(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.play();\n  }\n}\n\nfunction cancel(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.cancel();\n  }\n}\n\n/**\n * Lands every transition still running under `host` at its end value. A transition asks for frames\n * until it ends, on screen or not, and a host that stops showing must ask for none.\n */\nexport function landTransitions(host: Element) {\n  const animations = host.getAnimations({ subtree: true });\n  for (const animation of animations) {\n    const isTransition = animation instanceof CSSTransition;\n    if (isTransition) {\n      animation.finish();\n    }\n  }\n}\n\n/**\n * Endless animations that run only while their host shows. Off screen and in a hidden tab they are\n * paused: they keep their place and ask for no frame. They start paused, and `onShowing` is told\n * each time the host starts or stops showing. The function returned removes them.\n */\nexport function runWhileShowing(\n  host: Element,\n  animations: Animation[],\n  onShowing?: (isShowing: boolean) => void\n) {\n  hold(animations);\n  const stopWatching = watchShowing(host, (isShowing) => {\n    onShowing?.(isShowing);\n    if (isShowing) {\n      resume(animations);\n      return;\n    }\n    hold(animations);\n  });\n  return () => {\n    stopWatching();\n    cancel(animations);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/loops.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/math.ts",
      "content": "export type Point = {\n  x: number;\n  y: number;\n};\n\nexport function clamp(value: number, low: number, high: number) {\n  const raised = Math.max(low, value);\n  return Math.min(high, raised);\n}\n\n/** How much of the way left is covered in `seconds`: the same glide whatever the frame rate. */\nexport function shareOf(seconds: number, easeSeconds: number) {\n  const ratio = seconds / easeSeconds;\n  const decay = Math.exp(-ratio);\n  return 1 - decay;\n}\n\nexport function towards(from: number, to: number, share: number) {\n  const gap = to - from;\n  const step = gap * share;\n  return from + step;\n}\n\n/** From 0 at `low` to 1 at `high`, leaving and arriving without a corner. */\nexport function smoothstep(low: number, high: number, value: number) {\n  const span = high - low;\n  const offset = value - low;\n  const raw = offset / span;\n  const share = clamp(raw, 0, 1);\n  const doubled = 2 * share;\n  const curve = 3 - doubled;\n  const squared = share * share;\n  return squared * curve;\n}\n\n/** The damping that brings a spring of this stiffness to its target without passing it. */\nexport function dampingOf(stiffness: number) {\n  const root = Math.sqrt(stiffness);\n  return 2 * root;\n}\n\n/**\n * The speed of a spring one step later. The caller then moves the value by that speed over the\n * same step, so the value follows its target and settles on it.\n */\nexport function springSpeedOf(\n  gap: number,\n  speed: number,\n  stiffness: number,\n  damping: number,\n  seconds: number\n) {\n  const pull = gap * stiffness;\n  const drag = speed * damping;\n  const push = pull - drag;\n  const gain = push * seconds;\n  return speed + gain;\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/math.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/pointer.ts",
      "content": "import type { Point } from './math';\n\n/**\n * Where a point of the window falls inside `host`, in the pixels of its own box, or null when it\n * falls outside. A host that an ancestor scales still answers in its own pixels.\n */\nexport function placeIn(\n  host: HTMLElement,\n  clientX: number,\n  clientY: number\n): Point | null {\n  const box = host.getBoundingClientRect();\n  const isEmpty = box.width === 0 || box.height === 0;\n  if (isEmpty) {\n    return null;\n  }\n  const isInsideX = clientX >= box.left && clientX <= box.right;\n  const isInsideY = clientY >= box.top && clientY <= box.bottom;\n  if (!isInsideX || !isInsideY) {\n    return null;\n  }\n  const scaleX = host.offsetWidth / box.width;\n  const scaleY = host.offsetHeight / box.height;\n  const offsetX = clientX - box.left;\n  const offsetY = clientY - box.top;\n  const x = offsetX * scaleX;\n  const y = offsetY * scaleY;\n  return { x, y };\n}\n\n/** Where the centre of `element` falls inside `host`, or null when it falls outside. */\nexport function centreIn(host: HTMLElement, element: Element) {\n  const box = element.getBoundingClientRect();\n  const halfWidth = box.width / 2;\n  const halfHeight = box.height / 2;\n  const centreX = box.left + halfWidth;\n  const centreY = box.top + halfHeight;\n  return placeIn(host, centreX, centreY);\n}\n\n/**\n * Follows a pointer that can hover, from the window: content laid over a background takes the\n * pointer events of its own box, so a listener on the background would go blind under a headline.\n * The place is read once a frame at most, and only on a frame where the pointer moved. `onMove`\n * gets the place inside `host`; `onLeave` is called once when the pointer goes out of it. The\n * function returned ends the watch, and lets go of a pointer that was still over the host.\n */\nexport function watchPointer(\n  host: HTMLElement,\n  onMove: (x: number, y: number) => void,\n  onLeave: () => void\n) {\n  let clientX = 0;\n  let clientY = 0;\n  let frame = 0;\n  let isOver = false;\n\n  function leave() {\n    if (!isOver) {\n      return;\n    }\n    isOver = false;\n    onLeave();\n  }\n\n  function read() {\n    frame = 0;\n    const place = placeIn(host, clientX, clientY);\n    if (!place) {\n      leave();\n      return;\n    }\n    isOver = true;\n    onMove(place.x, place.y);\n  }\n\n  function onPointerMove(event: PointerEvent) {\n    const isTouch = event.pointerType === 'touch';\n    if (isTouch) {\n      return;\n    }\n    clientX = event.clientX;\n    clientY = event.clientY;\n    const isReadComing = frame !== 0;\n    if (isReadComing) {\n      return;\n    }\n    frame = requestAnimationFrame(read);\n  }\n\n  function onPointerGone() {\n    cancelAnimationFrame(frame);\n    frame = 0;\n    leave();\n  }\n\n  const root = document.documentElement;\n  window.addEventListener('pointermove', onPointerMove, { passive: true });\n  root.addEventListener('pointerleave', onPointerGone, { passive: true });\n\n  return () => {\n    window.removeEventListener('pointermove', onPointerMove);\n    root.removeEventListener('pointerleave', onPointerGone);\n    onPointerGone();\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/pointer.ts"
    },
    {
      "path": "registry/ndeko/components/background/_parts/showing.ts",
      "content": "/**\n * A background sits behind content for as long as the page is open, so everything it runs hangs on\n * this answer: its box touches the screen and the tab is the one looked at. `onChange` is called\n * each time the answer changes, starting from \"not showing\". The function returned ends the watch.\n */\nexport function watchShowing(\n  element: Element,\n  onChange: (isShowing: boolean) => void\n) {\n  let isOnScreen = false;\n  let wasShowing = false;\n\n  function report() {\n    const isTabShown = document.visibilityState === 'visible';\n    const isShowing = isOnScreen && isTabShown;\n    if (isShowing === wasShowing) {\n      return;\n    }\n    wasShowing = isShowing;\n    onChange(isShowing);\n  }\n\n  function onCrossing(entries: IntersectionObserverEntry[]) {\n    const latest = entries.at(-1);\n    if (!latest) {\n      return;\n    }\n    isOnScreen = latest.isIntersecting;\n    report();\n  }\n\n  const observer = new IntersectionObserver(onCrossing);\n  observer.observe(element);\n  document.addEventListener('visibilitychange', report);\n\n  return () => {\n    observer.disconnect();\n    document.removeEventListener('visibilitychange', report);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/showing.ts"
    }
  ],
  "categories": [
    "components",
    "background"
  ],
  "type": "registry:component"
}