---
title: Lamina
description: Stacked translucent wave sheets with soft pointer shift.
group: Backgrounds
order: 15
new: true
---

Layered ribbons drift like sliced water. Move over the field and the stack shifts a little with you.

```tsx
import { Lamina } from "@/components/motion/lamina"

export function Example() {
  return (
    <Lamina
      className="h-[420px] w-full"
      color="#3b82f6"
      layers={6}
    />
  )
}
```

## Installation

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

## Usage

```tsx
import { Lamina } from "@/components/motion/lamina"
```

```tsx
<Lamina
  className="h-[420px] w-full"
  color="#3b82f6"
  layers={6}
/>
```

## API Reference

### Lamina

| Prop | Type | Default |
| --- | --- | --- |
| color | string | #3b82f6 |
| backgroundColor | string | — |
| layers | number | 6 |
| speed | number | 1 |
| amplitude | number | 1 |
| influence | number | 0.7 |
| opacity | number | 0.55 |
| 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";

export type LaminaProps = Omit<ComponentProps<"div">, "children" | "color"> & {
  /** Ribbon tint. */
  color?: string;
  /** Optional solid fill behind the field. */
  backgroundColor?: string;
  /** How many stacked sheets. */
  layers?: number;
  /** Drift rate. */
  speed?: number;
  /** Wave height. */
  amplitude?: number;
  /** How much the pointer shifts the stack. */
  influence?: number;
  /** Master alpha (0–1). */
  opacity?: number;
  /** Cap for device pixel ratio. */
  dpr?: number;
  children?: ReactNode;
};

type Rgba = { r: number; g: number; b: number };

function parseColor(color: string): Rgba {
  if (typeof document === "undefined") return { r: 59, g: 130, b: 246 };
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 1;
  const ctx = canvas.getContext("2d");
  if (!ctx) return { r: 59, g: 130, b: 246 };
  ctx.fillStyle = "#000";
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const [r = 59, g = 130, b = 246] = ctx.getImageData(0, 0, 1, 1).data;
  return { r, g, b };
}

function Lamina({
  className,
  color = "#3b82f6",
  backgroundColor,
  layers = 6,
  speed = 1,
  amplitude = 1,
  influence = 0.7,
  opacity = 0.55,
  dpr: dprCap = 1.5,
  children,
  style,
  ...props
}: LaminaProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({
    color,
    backgroundColor,
    layers,
    speed,
    amplitude,
    influence,
    opacity,
    dprCap,
  });

  paramsRef.current = {
    color,
    backgroundColor,
    layers,
    speed,
    amplitude,
    influence,
    opacity,
    dprCap,
  };

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

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let raf = 0;
    let running = true;
    let inView = true;
    let last = performance.now();
    let time = 0;
    let colorKey = "";
    let rgba: Rgba = { r: 59, g: 130, b: 246 };
    let layoutKey = "";
    let cssW = 1;
    let cssH = 1;

    const pointer = {
      x: 0.5,
      y: 0.5,
      tx: 0.5,
      ty: 0.5,
      active: false,
    };

    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));
      canvas!.width = Math.floor(cssW * dpr);
      canvas!.height = Math.floor(cssH * dpr);
      canvas!.style.width = `${cssW}px`;
      canvas!.style.height = `${cssH}px`;
      layoutKey = `${cssW}x${cssH}:${p.dprCap}`;
    }

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

    function onPointerMove(event: PointerEvent) {
      const rect = root!.getBoundingClientRect();
      pointer.tx = (event.clientX - rect.left) / Math.max(rect.width, 1);
      pointer.ty = (event.clientY - rect.top) / Math.max(rect.height, 1);
      pointer.active = true;
    }

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

    function onPointerLeave() {
      pointer.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);
      }

      if (p.color !== colorKey) {
        rgba = parseColor(p.color);
        colorKey = p.color;
      }

      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const master = Math.min(Math.max(p.opacity, 0), 1);
      const count = Math.max(Math.round(p.layers), 2);
      const amp = Math.max(p.amplitude, 0.05) * cssH * 0.055;
      const pull = Math.min(Math.max(p.influence, 0), 1.5);
      const t = reduced.matches ? 0 : time;

      const targetX = pointer.active ? pointer.tx : 0.5;
      const targetY = pointer.active ? pointer.ty : 0.5;
      pointer.x += (targetX - pointer.x) * Math.min(dt * 6, 1);
      pointer.y += (targetY - pointer.y) * Math.min(dt * 6, 1);
      const shiftX = (pointer.x - 0.5) * pull * cssW * 0.08;
      const shiftY = (pointer.y - 0.5) * pull * cssH * 0.1;
      const padX = cssW * 0.28;
      const padY = cssH * 0.22;
      const drawLeft = -padX;
      const drawRight = cssW + padX;
      const drawBottom = cssH + padY;

      ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx!.clearRect(0, 0, cssW, cssH);

      if (p.backgroundColor) {
        ctx!.fillStyle = p.backgroundColor;
        ctx!.fillRect(0, 0, cssW, cssH);
      }

      const step = Math.max(cssW / 90, 4);

      for (let layer = count - 1; layer >= 0; layer--) {
        const depth = layer / Math.max(count - 1, 1);
        const baseY = cssH * (0.18 + depth * 0.62);
        const phase = t * (0.55 + depth * 0.45) + layer * 0.9;
        const waveAmp = amp * (0.55 + depth * 0.9);
        const layerShiftX = shiftX * (0.35 + depth * 0.9);
        const layerShiftY = shiftY * (0.25 + depth * 0.85);
        const alpha = master * (0.12 + (1 - depth) * 0.22);

        ctx!.beginPath();
        ctx!.moveTo(drawLeft + layerShiftX, drawBottom);

        for (let x = drawLeft; x <= drawRight + step; x += step) {
          const nx = x / cssW;
          const y =
            baseY +
            layerShiftY +
            Math.sin(nx * Math.PI * 2 * (1.4 + depth * 0.7) + phase) * waveAmp +
            Math.sin(nx * Math.PI * 2 * 2.6 - phase * 1.2 + layer) *
              waveAmp *
              0.35;
          ctx!.lineTo(x + layerShiftX, y);
        }

        ctx!.lineTo(drawRight + layerShiftX, drawBottom);
        ctx!.closePath();

        const gradient = ctx!.createLinearGradient(
          0,
          baseY - waveAmp * 2 - padY,
          0,
          drawBottom,
        );
        gradient.addColorStop(
          0,
          `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${alpha})`,
        );
        gradient.addColorStop(
          1,
          `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${alpha * 0.05})`,
        );
        ctx!.fillStyle = gradient;
        ctx!.fill();

        ctx!.beginPath();
        for (let x = drawLeft; x <= drawRight + step; x += step) {
          const nx = x / cssW;
          const y =
            baseY +
            layerShiftY +
            Math.sin(nx * Math.PI * 2 * (1.4 + depth * 0.7) + phase) * waveAmp +
            Math.sin(nx * Math.PI * 2 * 2.6 - phase * 1.2 + layer) *
              waveAmp *
              0.35;
          if (x === drawLeft) ctx!.moveTo(x + layerShiftX, y);
          else ctx!.lineTo(x + layerShiftX, y);
        }
        ctx!.strokeStyle = `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${alpha * 1.35})`;
        ctx!.lineWidth = 1.1;
        ctx!.stroke();
      }

      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="lamina"
      className={cn("arctis-lamina relative overflow-hidden", className)}
      style={style as CSSProperties}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="arctis-lamina-canvas pointer-events-none absolute inset-0 size-full"
        aria-hidden="true"
      />
      {children ? (
        <div className="relative z-10">{children}</div>
      ) : null}
    </div>
  );
}

export { Lamina };
```
