Mikkel Manniche
Dansk English Deutsch

Flader

Liquid metal

En flade af flydende krom fra en lille WebGL-shader, med stille metalbånd som reserve.

Forhåndsvisning

Brug

liquid-metal-demo.tsx
import { ArrowRight } from 'lucide-react'
import { LiquidMetal } from '@/components/liquid-metal'

export default function LiquidMetalDemo() {
  return (
    <div className="flex flex-col items-center gap-6">
      <LiquidMetal className="aspect-[1.586] w-full max-w-sm rounded-3xl shadow-xl">
        <div className="flex h-full flex-col justify-between p-6 text-neutral-950">
          <p className="text-sm font-semibold tracking-[0.2em] uppercase">Members</p>
          <div>
            <p className="font-mono text-lg tracking-widest">0042 1987 5531</p>
            <p className="text-sm">Platinum since 2021</p>
          </div>
        </div>
      </LiquidMetal>
      <LiquidMetal className="rounded-full p-[3px]">
        <button type="button" className="inline-flex min-h-11 items-center gap-2 rounded-full bg-neutral-950 px-5 text-sm font-medium text-white">
          Join the club
          <ArrowRight className="size-4" aria-hidden />
        </button>
      </LiquidMetal>
    </div>
  )
}

Kilde

liquid-metal.tsx
import { useEffect, useRef, type ReactNode } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { cn } from '@/lib/utils'

export type LiquidMetalProps = {
  /** Flow speed; 1 is slow and heavy. */
  speed?: number
  /** Content shown on top of the metal. */
  children?: ReactNode
  className?: string
}

const VERTEX = `attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}`

// The plane is folded a few times with sine waves, then read through a chrome palette of dark and bright bands.
const FRAGMENT = `precision mediump float;
uniform vec2 r;uniform float t;
void main(){
  vec2 p=(gl_FragCoord.xy*2.-r)/min(r.x,r.y);
  for(int i=1;i<5;i++){
    float f=float(i);
    p+=vec2(.6/f*sin(f*p.y+t+.3*f),.4/f*sin(f*p.x+t*.8+.3*f));
  }
  float v=.5+.5*sin(p.x*2.2+p.y*1.4);
  float band=pow(abs(sin(v*9.42)),.6);
  vec3 c=mix(vec3(.16,.17,.2),vec3(.96,.97,.99),band);
  c+=vec3(.05,.02,.08)*sin(v*12.);
  gl_FragColor=vec4(c,1.);
}`

/** A surface of flowing chrome, drawn with a tiny WebGL shader. Without WebGL it falls back to still metal bands. */
export function LiquidMetal({ speed = 1, children, className }: LiquidMetalProps) {
  const canvas = useRef<HTMLCanvasElement>(null)
  const reduce = useReducedMotion()

  useEffect(() => {
    const el = canvas.current
    const gl = el?.getContext('webgl', { antialias: false, premultipliedAlpha: false })
    if (!el || !gl) return

    const shader = (type: number, src: string) => {
      const s = gl.createShader(type)!
      gl.shaderSource(s, src)
      gl.compileShader(s)
      return s
    }
    const program = gl.createProgram()!
    gl.attachShader(program, shader(gl.VERTEX_SHADER, VERTEX))
    gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAGMENT))
    gl.linkProgram(program)
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return
    gl.useProgram(program)

    // One triangle that covers the whole canvas.
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
    gl.enableVertexAttribArray(0)
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
    const uRes = gl.getUniformLocation(program, 'r')
    const uTime = gl.getUniformLocation(program, 't')

    const draw = (now: number) => {
      gl.uniform1f(uTime, (now / 1000) * 0.35 * speed)
      gl.drawArrays(gl.TRIANGLES, 0, 3)
    }

    const resize = () => {
      const dpr = Math.min(1.5, window.devicePixelRatio || 1)
      el.width = Math.max(1, Math.round(el.clientWidth * dpr))
      el.height = Math.max(1, Math.round(el.clientHeight * dpr))
      gl.viewport(0, 0, el.width, el.height)
      gl.uniform2f(uRes, el.width, el.height)
      draw(reduce ? 4000 : performance.now())
    }
    const ro = new ResizeObserver(resize)
    ro.observe(el)
    el.dataset.ready = ''

    let raf = 0
    let visible = false
    const loop = (now: number) => {
      draw(now)
      raf = requestAnimationFrame(loop)
    }
    // Only animate while on screen.
    const io = new IntersectionObserver(([entry]) => {
      if (reduce || entry.isIntersecting === visible) return
      visible = entry.isIntersecting
      if (visible) raf = requestAnimationFrame(loop)
      else cancelAnimationFrame(raf)
    })
    io.observe(el)

    return () => {
      ro.disconnect()
      io.disconnect()
      cancelAnimationFrame(raf)
      delete el.dataset.ready
      gl.getExtension('WEBGL_lose_context')?.loseContext()
    }
  }, [speed, reduce])

  return (
    <div
      className={cn(
        'relative isolate overflow-hidden',
        // Fallback until (or if) WebGL starts: still chrome bands.
        'bg-[linear-gradient(115deg,#2a2c33_0%,#f4f5f8_22%,#6b6e78_40%,#eef0f4_58%,#30323a_78%,#d9dbe1_100%)]',
        className,
      )}
    >
      <canvas ref={canvas} aria-hidden className="absolute inset-0 -z-10 size-full opacity-0 transition-opacity duration-500 data-ready:opacity-100" />
      {children}
    </div>
  )
}
use-reduced-motion.ts
import { useSyncExternalStore } from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  )
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/liquid-metal.json

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/liquid-metal

Afhængigheder

Filer

  • liquid-metal.tsx
  • use-reduced-motion.ts