{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-01",
  "title": "Drifting fields",
  "description": "Soft fields of one hue behind a hero or a closing band, each drifting on a path of its own length, so they never move in step.",
  "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-01.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 { DemoBand } from './_parts/demo-band';\nimport { DEFAULT_HUE, useHueCycle } from './_parts/hue';\nimport { runWhileShowing } from './_parts/loops';\nimport { softFieldOf } from './_parts/soft-field';\n\ntype Field = {\n  id: string;\n  style: CSSProperties;\n  /** Milliseconds for one round of the path. The three differ, so the fields never drift in step. */\n  periodMs: number;\n  path: Keyframe[];\n};\n\nconst FIELD_SHAPE = 'closest-side';\n\nfunction restOf(\n  left: string,\n  top: string,\n  width: string,\n  strength: number\n): CSSProperties {\n  const backgroundImage = softFieldOf(FIELD_SHAPE, strength);\n  return { left, top, width, backgroundImage };\n}\n\n/** Each leg eases in and out, so a field turns at a waypoint without a corner in its path. */\nfunction legTo(transform: string): Keyframe {\n  return { transform, easing: 'ease-in-out' };\n}\n\nconst FIELDS: Field[] = [\n  {\n    id: 'near',\n    style: restOf('26%', '34%', 'min(64%, 46rem)', 24),\n    periodMs: 47000,\n    path: [\n      legTo('translate(0%, 0%) scale(1)'),\n      legTo('translate(14%, 8%) scale(1.08)'),\n      legTo('translate(5%, -10%) scale(0.94)'),\n      legTo('translate(0%, 0%) scale(1)'),\n    ],\n  },\n  {\n    id: 'far',\n    style: restOf('74%', '70%', 'min(56%, 40rem)', 17),\n    periodMs: 61000,\n    path: [\n      legTo('translate(0%, 0%) scale(1)'),\n      legTo('translate(-12%, -9%) scale(0.93)'),\n      legTo('translate(-4%, 10%) scale(1.07)'),\n      legTo('translate(0%, 0%) scale(1)'),\n    ],\n  },\n  {\n    id: 'high',\n    style: restOf('54%', '16%', 'min(42%, 30rem)', 13),\n    periodMs: 79000,\n    path: [\n      legTo('translate(0%, 0%) scale(1)'),\n      legTo('translate(10%, 12%) scale(1.1)'),\n      legTo('translate(-13%, 5%) scale(0.96)'),\n      legTo('translate(0%, 0%) scale(1)'),\n    ],\n  },\n];\n\nfunction driftsOf(host: HTMLElement, fields: Field[]) {\n  const drifts: Animation[] = [];\n  fields.forEach((field, index) => {\n    const element = host.children.item(index);\n    if (!element) {\n      return;\n    }\n    const drift = element.animate(field.path, {\n      duration: field.periodMs,\n      iterations: Infinity,\n    });\n    drifts.push(drift);\n  });\n  return drifts;\n}\n\nexport type DriftingFieldsProps = {\n  className?: string;\n  /** Any colour token, such as `var(--primary)` or `var(--series-2)`. The fields wear it at different strengths. */\n  hue?: string;\n  /** How many fields drift. */\n  count?: 1 | 2 | 3;\n};\n\n/**\n * Up to three soft fields of one hue that drift behind content. Each is a radial gradient that\n * fades to nothing, moved by a transform alone: no filter, and nothing repainted while they drift.\n * It fills its parent, which must be positioned. Under reduced motion the fields rest in place.\n */\nexport function DriftingFields({\n  className,\n  hue = DEFAULT_HUE,\n  count = 3,\n}: DriftingFieldsProps) {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const fields = FIELDS.slice(0, count);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host || shouldReduceMotion) {\n      return;\n    }\n    const drifting = FIELDS.slice(0, count);\n    const drifts = driftsOf(host, drifting);\n    return runWhileShowing(host, drifts);\n  }, [count, shouldReduceMotion]);\n\n  return (\n    <div\n      ref={hostRef}\n      aria-hidden='true'\n      style={{ color: hue }}\n      className={cn(\n        'pointer-events-none absolute inset-0 overflow-hidden transition-colors duration-500',\n        className\n      )}\n    >\n      {fields.map((field) => (\n        <div\n          key={field.id}\n          style={field.style}\n          className='absolute aspect-5/4 -translate-1/2'\n        />\n      ))}\n    </div>\n  );\n}\n\nexport default function Background01() {\n  const { hue, nextHue } = useHueCycle();\n\n  return (\n    <DemoBand\n      title='Three fields of one hue, drifting'\n      background={<DriftingFields hue={hue} />}\n    >\n      <Button onClick={nextHue}>Change hue</Button>\n    </DemoBand>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/background/background-01.tsx"
    },
    {
      "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/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"
    },
    {
      "path": "registry/ndeko/components/background/_parts/soft-field.ts",
      "content": "/** A fall from full to nothing with no visible edge: the share of the strength left at each quarter of the way out. */\nconst FALLOFF = [1, 0.85, 0.5, 0.15, 0];\n\nconst QUARTER = 25;\n\nfunction stopOf(strength: number, share: number, index: number) {\n  const exact = strength * share;\n  const scaled = Math.round(exact * 100);\n  const percent = scaled / 100;\n  const position = index * QUARTER;\n  return `color-mix(in oklab, currentColor ${percent}%, transparent) ${position}%`;\n}\n\n/**\n * A radial gradient of the current colour that fades to nothing with a soft edge, as a blur would\n * draw it, without a filter. `shape` is the shape and the place of the gradient, `strength` the\n * per cent of the colour at its heart.\n */\nexport function softFieldOf(shape: string, strength: number) {\n  const stops = FALLOFF.map((share, index) => stopOf(strength, share, index));\n  const ramp = stops.join(', ');\n  return `radial-gradient(${shape}, ${ramp})`;\n}\n",
      "type": "registry:component",
      "target": "components/background/_parts/soft-field.ts"
    }
  ],
  "categories": [
    "components",
    "background"
  ],
  "type": "registry:component"
}