Skip to content
UI Kit
Accent color

Gradient mesh

Layered radial gradients in accent tones, still or drifting slowly.

One of 6 components in Backgrounds

4 variants

Variants

Soft cloud

A pale cloud of tones behind the middle, calm enough for health and care.

Vivid

Overlapping tones fill the stage and drift, the hue turns slightly.

Corners

Light in two opposite corners, the copy stays on a clear middle. Still.

Horizon

The tones rise from the bottom edge like an early sky.

Details

What Gradient mesh brings along, read from the registry and the source.

Category
Backgrounds
Variants
4
Version
1.0.0 · since 1.0.0
File
components/ui/gradient-mesh.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The GradientMeshProps interface from components/ui/gradient-mesh.tsx, with the comments from the code.

  • variantoptional
    GradientMeshVariant

    softvividcornershorizon

    soft: a pale cloud of accent tones behind the middle. vivid: stronger, overlapping tones that fill the stage. corners: light gathers in two opposite corners, the middle stays clear. horizon: the tones rise from the bottom edge.

  • animateoptional
    boolean

    Let the tones drift slowly, default true

  • speedoptional
    number

    Seconds for one slow drift, default 26

  • parallaxoptional
    number

    Pixels the deepest tone drifts against the page over a full scroll pass, the others less. Default 40, 0 is off.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 176 lines
components/ui/gradient-mesh.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type GradientMeshVariant = 'soft' | 'vivid' | 'corners' | 'horizon';

export interface GradientMeshProps {
  /**
   * soft: a pale cloud of accent tones behind the middle.
   * vivid: stronger, overlapping tones that fill the stage.
   * corners: light gathers in two opposite corners, the middle stays clear.
   * horizon: the tones rise from the bottom edge.
   */
  variant?: GradientMeshVariant;
  /** Let the tones drift slowly, default true */
  animate?: boolean;
  /** Seconds for one slow drift, default 26 */
  speed?: number;
  /** Pixels the deepest tone drifts against the page over a full scroll pass, the others less. Default 40, 0 is off. */
  parallax?: number;
  className?: string;
}

/*
 * Tones from the accent. The second tone turns the hue a little where the
 * browser supports relative colors, otherwise it is a lighter accent. Light
 * mode keeps every tone thin so a dark accent never turns into grey fog.
 */
const CSS = `
.kitfx-mesh {
  --kitfx-m1: color-mix(in oklab, var(--primary) 20%, transparent);
  --kitfx-m2: color-mix(in oklab, var(--primary) 12%, transparent);
  --kitfx-m3: color-mix(in oklab, var(--primary) 9%, transparent);
  --kitfx-m4: color-mix(in oklch, var(--background) 90%, transparent);
}
:where(.dark) .kitfx-mesh, .kitfx-mesh:where(.dark *) {
  --kitfx-m1: color-mix(in oklab, var(--primary) 46%, transparent);
  --kitfx-m2: color-mix(in oklch, color-mix(in oklab, var(--primary) 60%, var(--foreground)) 26%, transparent);
  --kitfx-m3: color-mix(in oklab, var(--primary) 30%, transparent);
  --kitfx-m4: color-mix(in oklch, var(--background) 45%, transparent);
}
@supports (color: oklch(from red l c h)) {
  /*
   * Lightness is clamped, so a near-black accent never paints dark fog in light
   * mode and a near-white one never turns to milk in dark mode. --kitfx-n says
   * how neutral the accent is (1 grey default, 0 real color): a neutral accent
   * gets a breath of chroma and its tones fan out in hue, a pearl sheen of
   * lavender, rose and sky instead of grey clouds. The second tone turns away from
   * yellow: warm accents (orange, rose) lean toward red, cool ones toward violet, so a
   * dark orange never turns olive.
   */
  .kitfx-mesh {
    --kitfx-n: clamp(0, 1 - c / 0.06, 1);
    --kitfx-m1: oklch(from var(--primary) calc(max(l, 0.64) + 0.06 * var(--kitfx-n)) max(c, 0.085) h / calc(0.22 + 0.1 * var(--kitfx-n)));
    --kitfx-m2: oklch(from var(--primary) calc(max(l, 0.68) + 0.06 * var(--kitfx-n)) max(c, 0.075) calc(h - 38 * clamp(-1, cos((h - 75) * 1deg) * 3, 1) + 26 * var(--kitfx-n)) / calc(0.16 + 0.12 * var(--kitfx-n)));
    --kitfx-m3: oklch(from var(--primary) calc(max(l, 0.72) + 0.04 * var(--kitfx-n)) max(c, 0.07) calc(h - 80 * var(--kitfx-n)) / calc(0.14 + 0.12 * var(--kitfx-n)));
  }
  :where(.dark) .kitfx-mesh, .kitfx-mesh:where(.dark *) {
    --kitfx-m1: oklch(from var(--primary) calc(min(l, 0.7) - 0.02 * var(--kitfx-n)) max(c, 0.08) h / calc(0.42 + 0.04 * var(--kitfx-n)));
    --kitfx-m2: oklch(from var(--primary) min(l, 0.66) max(c, 0.075) calc(h - 38 * clamp(-1, cos((h - 75) * 1deg) * 3, 1) + 26 * var(--kitfx-n)) / 0.3);
    --kitfx-m3: oklch(from var(--primary) calc(min(l, 0.6) + 0.06 * var(--kitfx-n)) max(c, 0.07) calc(h - 80 * var(--kitfx-n)) / calc(0.3 + 0.04 * var(--kitfx-n)));
  }
}
@keyframes kitfx-mesh-drift {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  33% { transform: translate3d(var(--kitfx-dx), var(--kitfx-dy), 0) scale(1.08); }
  66% { transform: translate3d(calc(var(--kitfx-dx) * -0.6), calc(var(--kitfx-dy) * 0.5), 0) scale(0.96); }
}
.kitfx-mesh * { animation-play-state: var(--kitfx-play, running); }
@media (prefers-reduced-motion: reduce) { .kitfx-mesh * { animation: none !important; } .kitfx-mesh-depth { transform: none !important; } }
`;

interface Blob { x: number; y: number; w: number; h: number; tone: 1 | 2 | 3 | 4; dx: number; dy: number; t: number }

const LAYOUT: Record<GradientMeshVariant, Blob[]> = {
  soft: [
    { x: 30, y: 38, w: 55, h: 70, tone: 1, dx: 6, dy: 4, t: 1 },
    { x: 68, y: 46, w: 50, h: 64, tone: 2, dx: -5, dy: 5, t: 1.3 },
    { x: 50, y: 70, w: 60, h: 50, tone: 3, dx: 4, dy: -4, t: 1.6 },
    { x: 50, y: 42, w: 34, h: 40, tone: 4, dx: -3, dy: 3, t: 1.2 },
  ],
  vivid: [
    { x: 18, y: 28, w: 62, h: 80, tone: 1, dx: 8, dy: 6, t: 1 },
    { x: 80, y: 30, w: 58, h: 76, tone: 2, dx: -7, dy: 6, t: 1.25 },
    { x: 60, y: 82, w: 70, h: 70, tone: 1, dx: -5, dy: -6, t: 1.5 },
    { x: 26, y: 84, w: 54, h: 60, tone: 3, dx: 6, dy: -4, t: 1.15 },
    { x: 50, y: 48, w: 40, h: 46, tone: 4, dx: 3, dy: 3, t: 1.4 },
  ],
  corners: [
    { x: 4, y: 0, w: 58, h: 80, tone: 1, dx: 5, dy: 5, t: 1 },
    { x: 18, y: 10, w: 36, h: 50, tone: 2, dx: 4, dy: 3, t: 1.3 },
    { x: 96, y: 100, w: 58, h: 80, tone: 1, dx: -5, dy: -5, t: 1.15 },
    { x: 82, y: 92, w: 36, h: 50, tone: 2, dx: -4, dy: -3, t: 1.45 },
  ],
  horizon: [
    { x: 22, y: 104, w: 64, h: 70, tone: 1, dx: 6, dy: -4, t: 1 },
    { x: 54, y: 108, w: 70, h: 64, tone: 2, dx: -6, dy: -5, t: 1.3 },
    { x: 84, y: 102, w: 60, h: 70, tone: 3, dx: -4, dy: -4, t: 1.6 },
    { x: 50, y: 96, w: 46, h: 30, tone: 1, dx: 4, dy: -2, t: 1.15 },
  ],
};

/**
 * Scroll pass. While `on`, writes --kitfx-p on the element: 0 when its top
 * meets the bottom of the window, 1 when its bottom leaves at the top. Never
 * with reduced motion, the CSS then falls back to the resting value.
 */
function useScrollPass(ref: { current: HTMLElement | null }, on: boolean) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on]);
}

/**
 * Layered radial gradients in accent tones, still or drifting slowly.
 * Fills its parent (parent `relative isolate overflow-hidden`, content after
 * it with `relative`). Only transforms move, paused out of view, still with
 * reduced motion.
 */
export function GradientMesh({ variant = 'soft', animate = true, speed = 26, parallax = 40, className }: GradientMeshProps) {
  const ref = useRef<HTMLDivElement>(null);
  useScrollPass(ref, parallax !== 0);
  const blobs = LAYOUT[variant];
  useEffect(() => {
    const el = ref.current;
    if (!el || !animate || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
    io.observe(el);
    return () => io.disconnect();
  }, [animate]);

  return (
    <div ref={ref} aria-hidden data-clip-ok className={cn('kitfx-mesh pointer-events-none absolute inset-0 overflow-hidden', className)}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      {blobs.map((b, i) => (
        // Every tone on its own depth, the later ones nearer, so the cloud gains a little volume when the page moves
        <div key={i} className="kitfx-mesh-depth absolute inset-0" style={parallax ? { transform: `translate3d(0, calc((var(--kitfx-p, 0.5) - 0.5) * ${Math.round(parallax * (0.45 + (0.55 * i) / Math.max(1, blobs.length - 1)))}px), 0)` } : undefined}>
        <div
          className="absolute rounded-full"
          style={{
            left: `${b.x - b.w / 2}%`,
            top: `${b.y - b.h / 2}%`,
            width: `${b.w}%`,
            height: `${b.h}%`,
            background: `radial-gradient(closest-side, var(--kitfx-m${b.tone}), transparent)`,
            ['--kitfx-dx' as string]: `${b.dx}%`,
            ['--kitfx-dy' as string]: `${b.dy}%`,
            animation: animate ? `kitfx-mesh-drift ${Math.round(speed * b.t)}s ease-in-out ${-i * 4}s infinite` : undefined,
          }}
        />
        </div>
      ))}
    </div>
  );
}

Frequent questions

How do I install Gradient mesh?

With aiw add @ai-website/gradient-mesh. The command puts components/ui/gradient-mesh.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Gradient mesh have?

4 variants, Soft cloud, Vivid, Corners and Horizon. You pick one with the variant prop, for example variant="vivid".

Can an AI assistant build with Gradient mesh?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Gradient mesh with aiw_components and install it with aiw_add @ai-website/gradient-mesh. Name the variant in your prompt, for example variant="soft", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard