Chapter one

Before the storm

Then dust

The next chapter burns through the first one. Every component ships as source, installs with one command and runs on nothing but React.

pnpm dlx shadcn@latest add @uigrove/dust-transition

Dust Transition

A scroll-driven hand-off where the hero dollies in, a flare bursts at the focus and the next scene eats through it as glowing dust.

Installation
pnpm dlx shadcn@latest add @uigrove/dust-transition

Dependencies: none. Installs @uigrove/dot-canvas alongside.

First time? Add the registry to components.json
{
  "registries": {
    "@uigrove": "https://uigrove.me/r/{name}.json"
  }
}
Usage
import { DustTransition } from '@/components/uigrove/dust-transition'

<DustTransition hero={<Hero />} />
Props
NameTypeDefaultDescription
scrollLengthnumber1200Scroll length
dollynumber1.38Scale the hero reaches as it pushes in toward the focus
focusXnumber0.5Where the next scene breaks through, 0 is the left edge
focusYnumber0.58Where the next scene breaks through, 0 is the top edge
dustnumber1Roughness of the boundary and width of the dust band, 0 is a clean circle
flarenumber1Brightness of the burst and the glowing rim
sparksnumber1Hot specks flying off the rim, off under reduced motion
smoothingnumber0.14Smoothing
backgroundstring'#09090c'Stage color behind both scenes
viewportHeightstring'100dvh'CSS height of the sticky stage, 100cqh inside a sized container
classNamestring—Classes for the root element. Use it for sizing and position.
Code
'use client'

import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { useDotCanvas } from '@/hooks/use-dot-canvas'
import { DustTransitionEngine, type DustTransitionFrame } from './dust-transition.engine'

export interface DustTransitionOptions {
  scrollLength: number
  dolly: number
  focusX: number
  focusY: number
  dust: number
  flare: number
  sparks: number
  smoothing: number
  background: string
  viewportHeight: string
}

export interface DustTransitionProps extends Partial<DustTransitionOptions> {
  // The opening scene, dollies in toward the focus as you scroll
  hero: ReactNode
  // Next scene, bursts through the focus as dust
  children?: ReactNode
  className?: string
  style?: CSSProperties
}

export const dustTransitionDefaults: DustTransitionOptions = {
  scrollLength: 1200,
  dolly: 1.38,
  focusX: 0.5,
  focusY: 0.58,
  dust: 1,
  flare: 1,
  sparks: 1,
  smoothing: 0.14,
  background: '#09090c',
  viewportHeight: '100dvh',
}

export function DustTransition({
  hero,
  children,
  scrollLength = dustTransitionDefaults.scrollLength,
  dolly = dustTransitionDefaults.dolly,
  focusX = dustTransitionDefaults.focusX,
  focusY = dustTransitionDefaults.focusY,
  dust = dustTransitionDefaults.dust,
  flare = dustTransitionDefaults.flare,
  sparks = dustTransitionDefaults.sparks,
  smoothing = dustTransitionDefaults.smoothing,
  background = dustTransitionDefaults.background,
  viewportHeight = dustTransitionDefaults.viewportHeight,
  className,
  style,
}: DustTransitionProps) {
  const engineRef = useRef<DustTransitionEngine | null>(null)
  if (!engineRef.current) {
    engineRef.current = new DustTransitionEngine({
      dolly,
      focusX,
      focusY,
      dust,
      flare,
      sparks,
      smoothing,
      instant: false,
    })
  }
  const engine = engineRef.current
  const { containerRef, canvasRef, reducedMotion, wake } = useDotCanvas(engine, {
    pointer: false,
    kind: 'webgl',
  })
  const rootRef = useRef<HTMLDivElement>(null)
  const heroRef = useRef<HTMLDivElement>(null)
  const windowRef = useRef<HTMLDivElement>(null)
  const nextRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    engine.setOptions({
      dolly,
      focusX,
      focusY,
      dust,
      flare,
      sparks,
      smoothing,
      instant: reducedMotion,
    })
    wake()
  }, [engine, wake, dolly, focusX, focusY, dust, flare, sparks, smoothing, reducedMotion])

  useEffect(() => {
    const apply = (frame: DustTransitionFrame) => {
      const heroEl = heroRef.current
      const windowEl = windowRef.current
      const nextEl = nextRef.current
      if (heroEl) heroEl.style.transform = `scale(${frame.heroScale})`
      if (nextEl) nextEl.style.transform = `scale(${frame.nextScale})`
      if (windowEl) windowEl.style.clipPath = frame.clip
    }
    engine.onFrame = apply
    apply(engine.frame)
    return () => {
      engine.onFrame = null
    }
  }, [engine])

  useEffect(() => {
    const root = rootRef.current
    if (!root) return
    let first = true

    const measure = () => {
      const top = root.getBoundingClientRect().top
      engine.setTarget(-top / scrollLength, first)
      first = false
      wake()
    }

    measure()
    window.addEventListener('scroll', measure, { passive: true, capture: true })
    const resize = new ResizeObserver(measure)
    resize.observe(root)

    return () => {
      window.removeEventListener('scroll', measure, { capture: true })
      resize.disconnect()
    }
  }, [engine, wake, scrollLength])

  const origin = `${focusX * 100}% ${focusY * 100}%`

  return (
    <div
      ref={rootRef}
      className={['uig-dust-transition', 'relative w-full', className].filter(Boolean).join(' ')}
      style={{ height: `calc(${scrollLength}px + ${viewportHeight})`, ...style }}
    >
      <div
        ref={containerRef}
        className="uig-dust-transition__stage sticky top-0 isolate w-full overflow-hidden"
        style={{ height: viewportHeight, backgroundColor: background }}
      >
        <div
          ref={heroRef}
          className="uig-dust-transition__hero absolute inset-0"
          style={{ transformOrigin: origin, willChange: 'transform' }}
        >
          {hero}
        </div>
        <div
          ref={windowRef}
          className="uig-dust-transition__window absolute inset-0"
          style={{ clipPath: 'inset(100%)', willChange: 'clip-path' }}
        >
          <div
            ref={nextRef}
            className="uig-dust-transition__next absolute inset-0"
            style={{ transformOrigin: origin, willChange: 'transform' }}
          >
            {children}
          </div>
        </div>
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          role="presentation"
          className="uig-dust-transition__light pointer-events-none absolute inset-0 block h-full w-full mix-blend-plus-lighter"
        />
      </div>
    </div>
  )
}