# Hero 09

> Full-viewport product hero on a rounded card that zooms out as the page scrolls: an inline nav with brand and actions, a serif headline, sub-copy, dual CTAs, a labelled step-type strip, a drifting WebGL gradient mixed from the theme tokens, a bouncing scroll cue and a carved stat corner. Reduced-motion aware.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/hero-09).

- Page, with a live preview: https://shadcn.exchange/r/hirael/hero-09
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/hero-09-light-2fa9012cc3d4c7d9.png
- Registry item: https://hirael.com/r/hero-09.json
- Categories: blocks, hero
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/hero-09.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `button`

## Files

### `registry/hirael/bases/radix/blocks/hero-09/hero-09.tsx`

```tsx
// Hero 09 from Hirael <https://hirael.com/blocks/hero/hero-09>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import dynamic from 'next/dynamic';
import { Bell, CalendarClock, ChevronDown, Container, FlaskConical, Hammer, Rocket } from 'lucide-react';
import { motion, useReducedMotion, useScroll, useSpring, useTransform } from 'motion/react';

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';

// WebGL can't paint before hydration; the CSS wash below carries the first frame.
const Hero09Backdrop = dynamic(() => import('./hero-09-backdrop'), {
  ssr: false,
  loading: () => null,
});

const NAV_LINKS = ['Features', 'Pipeline', 'Pricing', 'FAQ'];

const STEP_TYPES = [
  { label: 'Build', icon: Hammer },
  { label: 'Test', icon: FlaskConical },
  { label: 'Deploy', icon: Rocket },
  { label: 'Containers', icon: Container },
  { label: 'Notify', icon: Bell },
  { label: 'Schedule', icon: CalendarClock },
];

const STATS = [
  { value: String(STEP_TYPES.length), label: 'step types' },
  { value: '40s', label: 'median queue to start' },
  { value: '$0', label: 'to start building' },
];

interface BrandMarkProps {
  className?: string;
}

const BrandMark = ({ className }: BrandMarkProps) => {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M2.3 12h2.4v10.95h6.2V14.6h4.6v8.35h6.2V12h-2.4V1.05h-6.2V9.4H8.5V1.05H2.3Z" />
    </svg>
  );
};

const ENTER =
  'animate-in fade-in slide-in-from-bottom-4 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
// Text only slides: starting it at opacity 0 would hold back Largest Contentful Paint.
const ENTER_TEXT =
  'animate-in slide-in-from-bottom-4 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

const stagger = (index: number): React.CSSProperties => ({ animationDelay: `${index * 70}ms` });

const ZOOM_AT = 0.55;
const SPRING = { stiffness: 200, damping: 40, mass: 0.4 };

const HeroZoom = ({ className, children }: React.ComponentProps<'div'>) => {
  const reduced = useReducedMotion();
  const [viewportHeight, setViewportHeight] = React.useState(0);
  const { scrollY } = useScroll();

  React.useEffect(() => {
    const measure = () => setViewportHeight(window.innerHeight);
    measure();
    window.addEventListener('resize', measure);

    return () => window.removeEventListener('resize', measure);
  }, []);

  // Effectively infinite until measured, so progress stays at 0 and the first paint doesn't jump.
  const end = viewportHeight > 0 ? viewportHeight * ZOOM_AT : 1e9;
  const progress = useTransform(scrollY, [0, end], [0, 1], { clamp: true });

  const scale = useSpring(useTransform(progress, [0, 1], [1, 0.89]), SPRING);
  const opacity = useSpring(useTransform(progress, [0, 1], [1, 0.88]), SPRING);
  const borderRadius = useSpring(useTransform(progress, [0, 1], [18, 30]), SPRING);

  if (reduced) return <div className={className}>{children}</div>;

  return (
    <motion.div className={className} style={{ scale, opacity, borderRadius }}>
      {children}
    </motion.div>
  );
};

const Hero09 = () => {
  return (
    <section data-slot="hero" className="relative min-h-svh bg-background pt-2.5">
      <HeroZoom className="sticky top-2.5 mx-2.5 flex min-h-200 origin-top flex-col overflow-hidden rounded-[18px] bg-card will-change-transform lg:h-[calc(100svh-20px)]">
        <div aria-hidden className="absolute inset-0">
          {/* First frame, and the fallback wherever WebGL is unavailable. */}
          <div className="absolute inset-0 bg-[radial-gradient(95%_65%_at_50%_-12%,color-mix(in_oklch,var(--primary)_34%,transparent),transparent_62%),radial-gradient(70%_60%_at_8%_104%,color-mix(in_oklch,var(--primary)_30%,transparent),transparent_66%),radial-gradient(60%_55%_at_96%_26%,color-mix(in_oklch,var(--chart-2)_20%,transparent),transparent_68%),radial-gradient(80%_50%_at_50%_108%,color-mix(in_oklch,var(--primary)_18%,transparent),transparent_70%)]" />
          <Hero09Backdrop className="absolute inset-0 size-full" />

          {/* Grain keeps the wide gradients from banding on large displays. */}
          <div
            className="absolute inset-0 opacity-[0.035] mix-blend-overlay"
            style={{
              backgroundImage:
                "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\")",
            }}
          />
        </div>

        <div className="relative z-10 flex flex-1 flex-col justify-between gap-10 p-6 pb-28 text-foreground md:p-10 md:pt-7">
          <div data-slot="hero-nav" className={cn(ENTER, 'relative flex items-center justify-between')}>
            <a href="#" data-slot="hero-brand" className="flex items-center gap-2">
              <BrandMark className="size-5 text-foreground" />
              <span className="text-base font-semibold tracking-tight">Hirael</span>
            </a>
            <nav className="absolute start-1/2 hidden items-center gap-6 md:flex ltr:-translate-x-1/2 rtl:translate-x-1/2">
              {NAV_LINKS.map((link) => (
                <a
                  key={link}
                  href={`#${link.toLowerCase()}`}
                  className="text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
                >
                  {link}
                </a>
              ))}
            </nav>
            <div className="flex items-center gap-2">
              <Button variant="ghost" size="sm" asChild>
                <a href="#">Sign in</a>
              </Button>
              <Button size="sm" variant="outline" asChild>
                <a href="#">Get started</a>
              </Button>
            </div>
          </div>

          <div className="mx-auto flex max-w-4xl flex-1 flex-col items-center justify-center gap-6 px-2 text-center">
            <h1
              data-slot="hero-title"
              style={stagger(1)}
              className={cn(
                ENTER_TEXT,
                'font-serif text-5xl leading-[1.05] font-medium tracking-tight text-balance md:text-6xl lg:text-7xl',
              )}
            >
              Pipelines you can see, <span className="text-primary">not just read</span>
            </h1>

            <p
              data-slot="hero-description"
              style={stagger(2)}
              className={cn(
                ENTER_TEXT,
                'max-w-2xl text-base leading-relaxed text-pretty text-muted-foreground md:text-lg',
              )}
            >
              Design workflows on a canvas, keep them in sync with YAML, and watch every run move step by step through
              the same graph.
            </p>

            <div
              data-slot="hero-actions"
              style={stagger(3)}
              className={cn(ENTER, 'flex flex-col gap-3 pt-1 sm:flex-row')}
            >
              <Button size="lg" asChild>
                <a href="#">Start building</a>
              </Button>
              <Button size="lg" variant="outline" asChild>
                <a href="#features">See the features</a>
              </Button>
            </div>

            <div
              data-slot="hero-steps"
              style={stagger(4)}
              className={cn(ENTER, 'mt-4 flex flex-col items-center gap-3.5')}
            >
              <span className="text-xs text-muted-foreground uppercase">Every step in one graph</span>
              <ul className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2.5 sm:gap-x-6 sm:gap-y-3">
                {STEP_TYPES.map((step) => (
                  <li
                    key={step.label}
                    className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors duration-150 hover:text-foreground sm:text-sm"
                  >
                    <step.icon aria-hidden className="size-4 text-primary/70" />
                    {step.label}
                  </li>
                ))}
              </ul>
            </div>

            <dl
              data-slot="hero-stats-inline"
              style={stagger(5)}
              className={cn(ENTER, 'mt-2 grid w-full max-w-sm grid-cols-3 gap-4 border-t border-border pt-5 md:hidden')}
            >
              {STATS.map((stat) => (
                <div key={stat.label} className="flex flex-col items-center gap-0.5">
                  <dt className="order-2 text-[11px] leading-tight text-muted-foreground uppercase">{stat.label}</dt>
                  <dd dir="ltr" className="order-1 font-serif text-xl font-medium text-primary tabular-nums">
                    {stat.value}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
        </div>

        <div className="pointer-events-none absolute inset-x-0 bottom-8 z-10 flex justify-center md:justify-start md:ps-10">
          <ChevronDown
            aria-hidden
            className="size-5 animate-bounce text-muted-foreground/60 motion-reduce:animate-none"
          />
        </div>

        <div
          data-slot="hero-stats"
          style={stagger(5)}
          className={cn(
            'absolute end-0 bottom-0 z-10 hidden items-end gap-8 rounded-ss-[18px] bg-background ps-7 pe-8 pt-5 pb-2.5 md:flex',
            'animate-in duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both fade-in motion-reduce:animate-none',
          )}
        >
          {STATS.map((stat) => (
            <div key={stat.label}>
              <div
                dir="ltr"
                className="font-serif text-2xl leading-tight font-medium text-primary tabular-nums md:text-3xl"
              >
                {stat.value}
              </div>
              <div className="text-xs text-muted-foreground uppercase">{stat.label}</div>
            </div>
          ))}
        </div>
      </HeroZoom>
    </section>
  );
};

export default Hero09;
```

### `registry/hirael/bases/radix/blocks/hero-09/hero-09-backdrop.tsx`

```tsx
// Hero 09 from Hirael <https://hirael.com/blocks/hero/hero-09>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';

type Rgb = [number, number, number];
type Oklch = { l: number; c: number; h: number };

type Palette = {
  base: Rgb;
  wisp: Rgb;
  sky: Rgb;
  deep: Rgb;
  blue: Rgb;
};

// Accent share per stop, the rest is `--background`; dark canvases need more to lift off it.
const SHADES = {
  light: { wisp: 0.16, sky: 0.52, deep: 0.4, blue: 0.22 },
  dark: { wisp: 0.26, sky: 0.72, deep: 0.58, blue: 0.36 },
} as const;

let probe: CanvasRenderingContext2D | null | undefined;

const getProbe = () => {
  if (probe !== undefined) return probe;
  const canvas = document.createElement('canvas');
  canvas.width = 1;
  canvas.height = 1;
  probe = canvas.getContext('2d', { willReadFrequently: true });

  return probe;
};

const resolveCssColor = (value: string): Rgb | null => {
  const ctx = getProbe();
  if (!ctx) return null;
  // An unparseable value leaves fillStyle untouched, so a sentinel detects it.
  ctx.fillStyle = '#000000';
  ctx.fillStyle = value;
  if (ctx.fillStyle === '#000000' && value.trim() !== '#000000') return null;
  ctx.fillRect(0, 0, 1, 1);
  const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
  if (r === undefined || g === undefined || b === undefined) return null;

  return [r / 255, g / 255, b / 255];
};

const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);

const srgbToOklch = ([r, g, b]: Rgb): Oklch => {
  const lr = toLinear(r);
  const lg = toLinear(g);
  const lb = toLinear(b);
  const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
  const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
  const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
  const okL = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
  const okA = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
  const okB = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;

  return { l: okL, c: Math.hypot(okA, okB), h: (Math.atan2(okB, okA) * 180) / Math.PI };
};

const oklchToLinearSrgb = ({ l: okL, c, h }: Oklch): Rgb => {
  const rad = (h * Math.PI) / 180;
  const okA = c * Math.cos(rad);
  const okB = c * Math.sin(rad);
  const l = (okL + 0.3963377774 * okA + 0.2158037573 * okB) ** 3;
  const m = (okL - 0.1055613458 * okA - 0.0638541728 * okB) ** 3;
  const s = (okL - 0.0894841775 * okA - 1.291485548 * okB) ** 3;

  return [
    4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
  ];
};

const inGamut = (rgb: Rgb) => rgb.every((c) => c >= -1e-4 && c <= 1 + 1e-4);

// Reduce chroma to fit sRGB (CSS Color 4 gamut mapping); clipping channels would shift the hue.
const oklchToSrgb = (color: Oklch): Rgb => {
  let lo = 0;
  let hi = color.c;
  if (inGamut(oklchToLinearSrgb(color))) lo = color.c;
  else {
    for (let i = 0; i < 24; i++) {
      const mid = (lo + hi) / 2;
      if (inGamut(oklchToLinearSrgb({ ...color, c: mid }))) lo = mid;
      else hi = mid;
    }
  }

  return oklchToLinearSrgb({ ...color, c: lo }).map((c) =>
    Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, c))))),
  ) as Rgb;
};

// Oklab, not Oklch: interpolating hue would sweep a cream background to the cool accent through green.
const mixOklab = (a: Oklch, b: Oklch, t: number): Oklch => {
  const toAB = ({ c, h }: Oklch) => {
    const rad = (h * Math.PI) / 180;

    return [c * Math.cos(rad), c * Math.sin(rad)] as const;
  };
  const [aA, aB] = toAB(a);
  const [bA, bB] = toAB(b);
  const okA = aA + (bA - aA) * t;
  const okB = aB + (bB - aB) * t;

  return {
    l: a.l + (b.l - a.l) * t,
    c: Math.hypot(okA, okB),
    h: (Math.atan2(okB, okA) * 180) / Math.PI,
  };
};

const buildPalette = (): Palette | null => {
  const styles = getComputedStyle(document.documentElement);
  const backgroundRgb = resolveCssColor(styles.getPropertyValue('--background'));
  const primaryRgb = resolveCssColor(styles.getPropertyValue('--primary'));
  const coolRgb = resolveCssColor(styles.getPropertyValue('--primary'));
  if (!backgroundRgb || !primaryRgb || !coolRgb) return null;

  const background = srgbToOklch(backgroundRgb);
  const primary = srgbToOklch(primaryRgb);
  const cool = srgbToOklch(coolRgb);
  const shades = background.l < 0.5 ? SHADES.dark : SHADES.light;
  const shade = (accent: Oklch, amount: number) => oklchToSrgb(mixOklab(background, accent, amount));

  return {
    base: backgroundRgb,
    wisp: shade(primary, shades.wisp),
    sky: shade(primary, shades.sky),
    deep: shade(cool, shades.deep),
    blue: shade(cool, shades.blue),
  };
};

const VERTEX_SHADER = 'attribute vec4 p;void main(){gl_Position=p;}';

const FRAGMENT_SHADER = `
  precision highp float;
  uniform vec2 u_resolution;
  uniform float u_time;
  uniform float u_dark;
  uniform vec3 u_base;
  uniform vec3 u_wisp;
  uniform vec3 u_sky;
  uniform vec3 u_deep;
  uniform vec3 u_blue;
  vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
  vec2 mod289(vec2 x){return x-floor(x*(1./289.))*289.;}
  vec3 permute(vec3 x){return mod289(((x*34.)+1.)*x);}
  float snoise(vec2 v){
    const vec4 C=vec4(.211324865405187,.366025403784439,-.577350269189626,.024390243902439);
    vec2 i=floor(v+dot(v,C.yy));
    vec2 x0=v-i+dot(i,C.xx);
    vec2 i1=(x0.x>x0.y)?vec2(1.,0.):vec2(0.,1.);
    vec4 x12=x0.xyxy+C.xxzz;
    x12.xy-=i1;
    i=mod289(i);
    vec3 pv=permute(permute(i.y+vec3(0.,i1.y,1.))+i.x+vec3(0.,i1.x,1.));
    vec3 m=max(.5-vec3(dot(x0,x0),dot(x12.xy,x12.xy),dot(x12.zw,x12.zw)),0.);
    m=m*m;m=m*m;
    vec3 x=2.*fract(pv*C.www)-1.;
    vec3 h=abs(x)-.5;
    vec3 ox=floor(x+.5);
    vec3 a0=x-ox;
    m*=1.79284291400159-.85373472095314*(a0*a0+h*h);
    vec3 g;
    g.x=a0.x*x0.x+h.x*x0.y;
    g.yz=a0.yz*x12.xz+h.yz*x12.yw;
    return 130.*dot(m,g);
  }
  void main(){
    vec2 st=gl_FragCoord.xy/u_resolution.xy;
    vec2 asp=st; asp.x*=u_resolution.x/u_resolution.y;
    float t=u_time*0.12;
    vec2 uv=st;
    uv.x+=snoise(asp*1.5+vec2(t*.4,t*.3))*0.25;
    uv.y+=snoise(asp*1.995-vec2(t*.2,t*.5))*0.25;
    float n1=snoise(uv*1.2+vec2(t,0.))*.5+.5;
    float n2=snoise(uv*1.5-vec2(0.,t*.6))*.5+.5;
    float n3=snoise(uv*1.3+vec2(-t*.5,t*.3))*.5+.5;
    vec3 bg=mix(u_base,u_blue,clamp(uv.x+n1*.4,0.,1.));
    bg=mix(bg,u_sky,smoothstep(.2,.9,n2*(1.2-uv.x)*uv.y));
    bg=mix(bg,u_deep,smoothstep(.1,.8,n1*uv.x*(1.1-uv.y)));
    bg=mix(bg,u_wisp,smoothstep(.3,1.,n3*(1.-uv.y)*uv.x*1.5));
    float grain=fract(sin(dot(gl_FragCoord.xy+u_time*100.,vec2(12.9898,78.233)))*43758.5453123);
    gl_FragColor=vec4(bg+(grain-.5)*mix(0.015,0.04,u_dark),1.);
  }
`;

const compile = (gl: WebGLRenderingContext, type: number, source: string) => {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  return shader;
};

interface Hero09BackdropProps {
  className?: string;
}

const Hero09Backdrop = ({ className }: Hero09BackdropProps) => {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const [themeTick, setThemeTick] = React.useState(0);

  React.useEffect(() => {
    const observer = new MutationObserver(() => setThemeTick((tick) => tick + 1));
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });

    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const onMedia = () => setThemeTick((tick) => tick + 1);
    media.addEventListener('change', onMedia);

    return () => {
      observer.disconnect();
      media.removeEventListener('change', onMedia);
    };
  }, []);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const gl = canvas.getContext('webgl');
    if (!gl) return;

    const program = gl.createProgram();
    const vertexShader = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
    const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
    if (!program || !vertexShader || !fragmentShader) return;

    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, -1, -1, -1, 1, 1, -1, 1]), gl.STATIC_DRAW);

    const position = gl.getAttribLocation(program, 'p');
    const uResolution = gl.getUniformLocation(program, 'u_resolution');
    const uTime = gl.getUniformLocation(program, 'u_time');
    const start = Date.now();

    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    let frame = 0;
    let running = false;
    let ready = false;
    // Assume on-screen so the hero paints at load; the observer's first callback corrects it.
    let onScreen = true;

    const draw = () => {
      if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
        canvas.width = canvas.clientWidth;
        canvas.height = canvas.clientHeight;
      }
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.useProgram(program);
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
      gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
      gl.enableVertexAttribArray(position);
      gl.uniform2f(uResolution, canvas.width, canvas.height);
      gl.uniform1f(uTime, still ? 0 : (Date.now() - start) / 1000);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      if (running && !still) frame = requestAnimationFrame(draw);
    };

    const play = () => {
      if (running || !ready || !onScreen || document.hidden) return;
      running = true;
      frame = requestAnimationFrame(draw);
    };
    const pause = () => {
      running = false;
      cancelAnimationFrame(frame);
    };

    // Next frame: the theme toggle flips the class in a parent effect, which runs after this one.
    const paletteFrame = requestAnimationFrame(() => {
      const palette = buildPalette();
      if (!palette) return;

      gl.useProgram(program);
      gl.uniform3fv(gl.getUniformLocation(program, 'u_base'), palette.base);
      gl.uniform3fv(gl.getUniformLocation(program, 'u_wisp'), palette.wisp);
      gl.uniform3fv(gl.getUniformLocation(program, 'u_sky'), palette.sky);
      gl.uniform3fv(gl.getUniformLocation(program, 'u_deep'), palette.deep);
      gl.uniform3fv(gl.getUniformLocation(program, 'u_blue'), palette.blue);
      gl.uniform1f(gl.getUniformLocation(program, 'u_dark'), srgbToOklch(palette.base).l < 0.5 ? 1 : 0);
      ready = true;
      play();
    });

    const observer = new IntersectionObserver(
      (entries) => {
        onScreen = entries[0]?.isIntersecting ?? false;
        if (onScreen) play();
        else pause();
      },
      { rootMargin: '200px' },
    );
    observer.observe(canvas);

    const onVisibility = () => (document.hidden ? pause() : play());
    document.addEventListener('visibilitychange', onVisibility);

    // Resizing the backing store clears the canvas, so repaint even when parked on a still frame.
    const onResize = () => {
      if (ready && still) draw();
    };
    window.addEventListener('resize', onResize);

    return () => {
      cancelAnimationFrame(paletteFrame);
      pause();
      observer.disconnect();
      document.removeEventListener('visibilitychange', onVisibility);
      window.removeEventListener('resize', onResize);
    };
  }, [themeTick]);

  return <canvas ref={canvasRef} data-slot="hero-09-backdrop" aria-hidden className={className} />;
};

export default Hero09Backdrop;
```
