---
title: Sheen
description: Liquid metal surface with a pointer-driven specular.
group: Backgrounds
order: 17
new: true
---

A flowing metal field with soft lighting. Move over it and the highlight follows. The metal tint is fixed so the specular stays the point.

```tsx
import { Sheen } from "@/components/motion/sheen"

export function Example() {
  return (
    <Sheen className="h-[420px] w-full" />
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add sheen
```

## Usage

```tsx
import { Sheen } from "@/components/motion/sheen"
```

```tsx
<Sheen className="h-[420px] w-full" />
```

## API Reference

### Sheen

| Prop | Type | Default |
| --- | --- | --- |
| backgroundColor | string | — |
| speed | number | 1 |
| amplitude | number | 1 |
| gloss | number | 28 |
| influence | number | 1 |
| opacity | number | 0.95 |
| soft | number | 2 |
| dpr | number | 1.5 |
| className | string | — |
| children | ReactNode | — |

Also accepts the other native `div` attributes.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

const METAL = { r: 96, g: 120, b: 160 };
const HIGHLIGHT = { r: 232, g: 238, b: 248 };

export type SheenProps = Omit<ComponentProps<"div">, "children" | "color"> & {
  /** Optional solid fill behind the field. */
  backgroundColor?: string;
  /** Flow rate of the liquid field. */
  speed?: number;
  /** Wave height of the surface. */
  amplitude?: number;
  /** Specular tightness. Higher is sharper. */
  gloss?: number;
  /** How strongly the pointer pulls the light. */
  influence?: number;
  /** Master alpha (0–1). */
  opacity?: number;
  /** Sample step in css px. Higher is cheaper and softer. */
  soft?: number;
  /** Cap for device pixel ratio. */
  dpr?: number;
  children?: ReactNode;
};

function mixChannel(a: number, b: number, t: number) {
  return Math.round(a + (b - a) * t);
}

function heightAt(x: number, y: number, t: number, amp: number) {
  const a = Math.max(amp, 0.05);
  return (
    a *
    (Math.sin(x * 0.011 + t * 0.85) * Math.cos(y * 0.009 - t * 0.55) +
      0.62 * Math.sin((x * 0.7 + y) * 0.0085 - t * 0.42) +
      0.4 * Math.cos(x * 0.0038 - y * 0.013 + t * 0.7) +
      0.22 * Math.sin(x * 0.021 - y * 0.017 + t * 1.1))
  );
}

function Sheen({
  className,
  backgroundColor,
  speed = 1,
  amplitude = 1,
  gloss = 28,
  influence = 1,
  opacity = 0.95,
  soft = 2,
  dpr: dprCap = 1.5,
  children,
  style,
  ...props
}: SheenProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({
    backgroundColor,
    speed,
    amplitude,
    gloss,
    influence,
    opacity,
    soft,
    dprCap,
  });

  paramsRef.current = {
    backgroundColor,
    speed,
    amplitude,
    gloss,
    influence,
    opacity,
    soft,
    dprCap,
  };

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;

    const ctx = canvas.getContext("2d", { alpha: true });
    if (!ctx) return;

    let raf = 0;
    let running = true;
    let inView = true;
    let last = performance.now();
    let time = 0;
    let layoutKey = "";
    let cssW = 1;
    let cssH = 1;
    let sampleW = 1;
    let sampleH = 1;
    let image: ImageData | null = null;
    const off = document.createElement("canvas");
    const offCtx = off.getContext("2d");

    const light = {
      x: 0,
      y: 0,
      tx: 0,
      ty: 0,
      active: false,
      visible: 0,
    };

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

    function rebuild() {
      const p = paramsRef.current;
      cssW = Math.max(root!.clientWidth, 1);
      cssH = Math.max(root!.clientHeight, 1);
      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const step = Math.max(p.soft, 1);
      sampleW = Math.max(Math.ceil(cssW / step), 1);
      sampleH = Math.max(Math.ceil(cssH / step), 1);
      canvas!.width = Math.floor(cssW * dpr);
      canvas!.height = Math.floor(cssH * dpr);
      canvas!.style.width = `${cssW}px`;
      canvas!.style.height = `${cssH}px`;
      off.width = sampleW;
      off.height = sampleH;
      image = offCtx ? offCtx.createImageData(sampleW, sampleH) : null;
      light.x = cssW * 0.62;
      light.y = cssH * 0.28;
      light.tx = light.x;
      light.ty = light.y;
      layoutKey = `${cssW}x${cssH}:${step}:${p.dprCap}`;
    }

    function ensureLayout() {
      const p = paramsRef.current;
      const next = `${Math.max(root!.clientWidth, 1)}x${Math.max(root!.clientHeight, 1)}:${Math.max(p.soft, 1)}:${p.dprCap}`;
      if (next !== layoutKey) rebuild();
    }

    function onPointerMove(event: PointerEvent) {
      const rect = root!.getBoundingClientRect();
      light.tx = event.clientX - rect.left;
      light.ty = event.clientY - rect.top;
      light.active = true;
    }

    function onPointerEnter(event: PointerEvent) {
      onPointerMove(event);
      light.active = true;
    }

    function onPointerLeave() {
      light.active = false;
    }

    function paint(now: number) {
      if (!running) return;
      ensureLayout();
      const p = paramsRef.current;
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;

      if (inView && !reduced.matches) {
        time += dt * Math.max(p.speed, 0);
      }

      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const master = Math.min(Math.max(p.opacity, 0), 1);
      const amp = Math.max(p.amplitude, 0.05);
      const gloss = Math.max(p.gloss, 4);
      const pull = Math.max(p.influence, 0);
      const step = Math.max(p.soft, 1);
      const t = reduced.matches ? 0 : time;

      const targetVisible = light.active ? 1 : 0;
      light.visible += (targetVisible - light.visible) * Math.min(dt * 7, 1);

      const driftX = cssW * (0.55 + 0.2 * Math.sin(t * 0.35));
      const driftY = cssH * (0.3 + 0.18 * Math.cos(t * 0.28));
      const follow = Math.min(Math.max(pull * light.visible, 0), 1);
      if (!light.active) {
        light.tx = driftX;
        light.ty = driftY;
      }
      light.x += (light.tx - light.x) * Math.min(dt * (6 + follow * 10), 1);
      light.y += (light.ty - light.y) * Math.min(dt * (6 + follow * 10), 1);

      if (!image || !offCtx) {
        rebuild();
        raf = window.requestAnimationFrame(paint);
        return;
      }

      const data = image.data;
      const elev = Math.max(cssW, cssH) * 0.55;
      const eps = step;

      for (let y = 0; y < sampleH; y++) {
        const py = (y + 0.5) * step;
        for (let x = 0; x < sampleW; x++) {
          const px = (x + 0.5) * step;
          const h = heightAt(px, py, t, amp);
          const hx = heightAt(px + eps, py, t, amp);
          const hy = heightAt(px, py + eps, t, amp);
          let nx = (h - hx) * 18;
          let ny = (h - hy) * 18;
          let nz = 1;
          const invLen = 1 / Math.max(Math.hypot(nx, ny, nz), 1e-5);
          nx *= invLen;
          ny *= invLen;
          nz *= invLen;

          const lx = light.x - px;
          const ly = light.y - py;
          const lz = elev;
          const lInv = 1 / Math.max(Math.hypot(lx, ly, lz), 1e-5);
          const ldx = lx * lInv;
          const ldy = ly * lInv;
          const ldz = lz * lInv;

          const ndotl = Math.max(nx * ldx + ny * ldy + nz * ldz, 0);
          const hxv = ldx;
          const hyv = ldy;
          const hzv = ldz + 1;
          const hInv = 1 / Math.max(Math.hypot(hxv, hyv, hzv), 1e-5);
          const ndoth = Math.max(
            nx * hxv * hInv + ny * hyv * hInv + nz * hzv * hInv,
            0,
          );
          const spec = Math.pow(ndoth, gloss);
          const fresnel = Math.pow(1 - Math.max(nz, 0), 2.2);
          const ambient = 0.22;
          const diffuse = ambient + ndotl * 0.55;
          const metal = diffuse * (0.78 + fresnel * 0.22);
          const shine = spec * (0.75 + follow * 0.35);

          const r = mixChannel(METAL.r * metal, HIGHLIGHT.r, shine);
          const g = mixChannel(METAL.g * metal, HIGHLIGHT.g, shine);
          const b = mixChannel(METAL.b * metal, HIGHLIGHT.b, shine);

          const i = (y * sampleW + x) * 4;
          data[i] = Math.min(r, 255);
          data[i + 1] = Math.min(g, 255);
          data[i + 2] = Math.min(b, 255);
          data[i + 3] = Math.round(master * 255);
        }
      }

      ctx!.setTransform(1, 0, 0, 1, 0, 0);
      ctx!.clearRect(0, 0, canvas!.width, canvas!.height);

      if (p.backgroundColor) {
        ctx!.fillStyle = p.backgroundColor;
        ctx!.fillRect(0, 0, canvas!.width, canvas!.height);
      }

      offCtx.putImageData(image, 0, 0);
      ctx!.imageSmoothingEnabled = true;
      ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx!.drawImage(off, 0, 0, cssW, cssH);

      raf = window.requestAnimationFrame(paint);
    }

    rebuild();
    last = performance.now();
    raf = window.requestAnimationFrame(paint);

    root.addEventListener("pointerenter", onPointerEnter);
    root.addEventListener("pointermove", onPointerMove, { capture: true });
    root.addEventListener("pointerleave", onPointerLeave);

    const resizeObserver = new ResizeObserver(() => rebuild());
    resizeObserver.observe(root);

    const intersectionObserver = new IntersectionObserver(
      ([entry]) => {
        inView = entry?.isIntersecting ?? true;
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    return () => {
      running = false;
      window.cancelAnimationFrame(raf);
      root.removeEventListener("pointerenter", onPointerEnter);
      root.removeEventListener("pointermove", onPointerMove, {
        capture: true,
      });
      root.removeEventListener("pointerleave", onPointerLeave);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
    };
  }, []);

  return (
    <div
      ref={rootRef}
      data-slot="sheen"
      className={cn("arctis-sheen relative overflow-hidden", className)}
      style={style as CSSProperties}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="arctis-sheen-canvas pointer-events-none absolute inset-0 size-full"
        aria-hidden="true"
      />
      {children ? (
        <div className="relative z-10">{children}</div>
      ) : null}
    </div>
  );
}

export { Sheen };
```
