pnpm dlx shadcn@latest add @uigrove/gooey-dots

Gooey Dots

Drifting dots that merge into one liquid shape when they come close.

Installation
pnpm dlx shadcn@latest add @uigrove/gooey-dots

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 { GooeyDots } from '@/components/uigrove/gooey-dots'

<GooeyDots className="absolute inset-0" />
Props
NameTypeDefaultDescription
countnumber24Count
sizenumber28Size
sizeVariancenumber0.4Size variance
colorstring'#ffffff'Color
goonumber0.6Goo
speednumber1Speed
wandernumber60Wander
cursorRadiusnumber220Cursor radius
cursorMode'attract' | 'repel' | 'none''attract'Cursor
cursorStrengthnumber260Cursor strength
seednumber7Seed
classNamestring—Classes for the root element. Use it for sizing and position.
Code
'use client'

import { useEffect, useId, useRef, type CSSProperties } from 'react'
import { useDotCanvas } from '@/hooks/use-dot-canvas'
import { GooeyDotsEngine, type GooeyDotsOptions } from './gooey-dots.engine'

export interface GooeyDotsProps extends Partial<GooeyDotsOptions> {
  goo: number
  className?: string
  style?: CSSProperties
}

export const gooeyDotsDefaults: GooeyDotsOptions & { goo: number } = {
  count: 24,
  size: 28,
  sizeVariance: 0.4,
  color: '#ffffff',
  speed: 1,
  wander: 60,
  cursorRadius: 220,
  cursorMode: 'attract',
  cursorStrength: 260,
  seed: 7,
  goo: 0.6,
}

export function GooeyDots({
  count = gooeyDotsDefaults.count,
  size = gooeyDotsDefaults.size,
  sizeVariance = gooeyDotsDefaults.sizeVariance,
  color = gooeyDotsDefaults.color,
  speed = gooeyDotsDefaults.speed,
  wander = gooeyDotsDefaults.wander,
  cursorRadius = gooeyDotsDefaults.cursorRadius,
  cursorMode = gooeyDotsDefaults.cursorMode,
  cursorStrength = gooeyDotsDefaults.cursorStrength,
  seed = gooeyDotsDefaults.seed,
  goo = gooeyDotsDefaults.goo,
  className,
  style,
}: Partial<GooeyDotsProps>) {
  const engineRef = useRef<GooeyDotsEngine | null>(null)
  if (!engineRef.current) engineRef.current = new GooeyDotsEngine(gooeyDotsDefaults)
  const engine = engineRef.current
  const filterId = useId()
  const { containerRef, canvasRef, wake } = useDotCanvas(engine, { pointer: cursorMode !== 'none' })

  useEffect(() => {
    engine.setOptions({
      count,
      size,
      sizeVariance,
      color,
      speed,
      wander,
      cursorRadius,
      cursorMode,
      cursorStrength,
      seed,
    })
    wake()
  }, [
    engine,
    wake,
    count,
    size,
    sizeVariance,
    color,
    speed,
    wander,
    cursorRadius,
    cursorMode,
    cursorStrength,
    seed,
  ])

  const blur = Math.max(0, goo) * size * 0.55
  const slope = 12 + goo * 28
  const intercept = -slope * 0.5 + 0.5

  return (
    <div
      ref={containerRef}
      className={['uig-gooey-dots', 'pointer-events-none overflow-hidden', className]
        .filter(Boolean)
        .join(' ')}
      style={style}
      aria-hidden="true"
    >
      <svg className="absolute size-0" aria-hidden="true">
        <defs>
          <filter id={filterId} colorInterpolationFilters="sRGB">
            <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="blur" />
            <feColorMatrix
              in="blur"
              type="matrix"
              values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${slope} ${intercept}`}
            />
          </filter>
        </defs>
      </svg>
      <canvas
        ref={canvasRef}
        className="block h-full w-full"
        style={goo > 0 ? { filter: `url(#${filterId})` } : undefined}
      />
    </div>
  )
}