Skip to content
UI Kit
Accent color

Shapes

Abstract shapes for decoration, drawn in SVG with the theme colors. Blobs, rings, arches, stacks, an orbit, a wave band, a flower, a dotted sphere, a squiggle and a star burst, with calm float, spin and breathe loops.

One of 3 components in Patterns and shapes

4 variants

Variants

Blobs

Organic outlines from a seed in three tones. They float on their own tempo and drift at different depths while you scroll.

Rings and orbits

Rings, an orbit with a moon, a star burst and a dotted sphere lit from the upper left. Switch the motion for all four.

Geometric set

Arch, stack, wave band, flower, squiggle and star burst. The tone switch shows accent, muted and outline.

Composition

Shapes placed around a headline. Each layer follows the pointer at its own depth and moves at its own speed while you scroll.

Details

What Shapes brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.0.0
File
components/ui/shapes.tsx
Brings along
utilskitmotion-scene
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The ShapeProps interface from components/ui/shapes.tsx, with the comments from the code.

  • kindoptional
    ShapeKind

    blobringsarchstackorbitwaveflowerspheresquiggleburst

  • seedoptional
    number
  • toneoptional
    ShapeTone

    accentmutedoutline

    accent (default) fills with the accent color, muted with a quiet neutral, outline draws only lines

  • sizeoptional
    number | string

    Width and height, a number in pixels or any CSS length. Default 160.

  • animateoptional
    ShapeMotion

    nonefloatspinbreathe

    float, spin and breathe are slow loops, default none

  • durationoptional
    number

    Seconds per loop, each motion has its own calm default

Accessibility

Read from the source of the component.

  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • Brings animations of its own, calmly timed and without wobble.

Source

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

Show the file, 253 lines
components/ui/shapes.tsx
'use client';

import { cn, round } from '@/lib/kit';
import { SCENE_CSS, rand, useScenePlay, useSceneId } from '@/components/ui/motion-scene';

/*
 * Abstract shapes for decoration: blobs, rings, arches, stacks, an orbit, a
 * wave band, a flower, a dotted sphere, a squiggle and a star burst. Drawn in
 * SVG with the kit tokens, so they follow the theme and the accent. Seeds
 * change the outline of blob, squiggle and burst and the angle of the orbit.
 *
 * The motion is calm and moves only transform. It pauses out of view and is
 * off with reduced motion, the shape then simply stands still.
 */

export type ShapeKind = 'blob' | 'rings' | 'arch' | 'stack' | 'orbit' | 'wave' | 'flower' | 'sphere' | 'squiggle' | 'burst';
export type ShapeTone = 'accent' | 'muted' | 'outline';
export type ShapeMotion = 'none' | 'float' | 'spin' | 'breathe';

export interface ShapeProps {
  kind?: ShapeKind;
  seed?: number;
  /** accent (default) fills with the accent color, muted with a quiet neutral, outline draws only lines */
  tone?: ShapeTone;
  /** Width and height, a number in pixels or any CSS length. Default 160. */
  size?: number | string;
  /** float, spin and breathe are slow loops, default none */
  animate?: ShapeMotion;
  /** Seconds per loop, each motion has its own calm default */
  duration?: number;
  className?: string;
}

/** A closed smooth outline through points (Catmull-Rom as cubic Bézier) */
function closedPath(pts: [number, number][]) {
  const n = pts.length;
  const p = (i: number) => pts[(i + n) % n];
  let d = `M${round(p(0)[0], 1)} ${round(p(0)[1], 1)}`;
  for (let i = 0; i < n; i++) {
    const [p0, p1, p2, p3] = [p(i - 1), p(i), p(i + 1), p(i + 2)];
    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
    d += `C${round(c1[0], 1)} ${round(c1[1], 1)} ${round(c2[0], 1)} ${round(c2[1], 1)} ${round(p2[0], 1)} ${round(p2[1], 1)}`;
  }
  return `${d}Z`;
}

/** An open smooth line through points */
function openPath(pts: [number, number][]) {
  let d = `M${round(pts[0][0], 1)} ${round(pts[0][1], 1)}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[Math.max(0, i - 1)];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[Math.min(pts.length - 1, i + 2)];
    d += `C${round(p1[0] + (p2[0] - p0[0]) / 6, 1)} ${round(p1[1] + (p2[1] - p0[1]) / 6, 1)} ${round(p2[0] - (p3[0] - p1[0]) / 6, 1)} ${round(p2[1] - (p3[1] - p1[1]) / 6, 1)} ${round(p2[0], 1)} ${round(p2[1], 1)}`;
  }
  return d;
}

const blobPoints = (seed: number, cx: number, cy: number, r: number, n = 7, wobble = 0.34): [number, number][] =>
  Array.from({ length: n }, (_, i) => {
    const a = (i / n) * Math.PI * 2 + rand(seed, 99) * 0.6;
    const rr = r * (1 - wobble / 2 + rand(seed, i) * wobble);
    return [cx + Math.cos(a) * rr, cy + Math.sin(a) * rr];
  });

const MOTION: Record<Exclude<ShapeMotion, 'none'>, { frames: string; time: number; ease: string }> = {
  float: { frames: '0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-6%,0)}', time: 7, ease: 'ease-in-out' },
  spin: { frames: 'to{transform:rotate(360deg)}', time: 60, ease: 'linear' },
  breathe: { frames: '0%,100%{transform:scale(0.95)}50%{transform:scale(1.04)}', time: 8, ease: 'ease-in-out' },
};

/**
 * One decorative shape. Sits inline at its size, place it absolutely to put it
 * behind or beside content.
 *
 *   <Shape kind="blob" seed={4} tone="accent" animate="float" size={220} />
 */
export function Shape({ kind = 'blob', seed = 1, tone = 'accent', size = 160, animate = 'none', duration, className }: ShapeProps) {
  const sid = useSceneId('ksh');
  const play = useScenePlay<HTMLSpanElement>('in-view');
  const g = `${sid}g`;
  const outline = tone === 'outline';
  const base = tone === 'accent' ? 'var(--primary)' : 'var(--foreground)';
  const lo = tone === 'accent' ? `color-mix(in oklab, var(--primary) 34%, var(--background))` : `color-mix(in oklab, var(--foreground) 9%, var(--background))`;
  const hi = tone === 'accent' ? `var(--primary)` : `color-mix(in oklab, var(--foreground) 26%, var(--background))`;
  const mid = tone === 'accent' ? `color-mix(in oklab, var(--primary) 66%, var(--background))` : `color-mix(in oklab, var(--foreground) 16%, var(--background))`;
  const line = outline ? 'color-mix(in oklab, var(--foreground) 46%, transparent)' : base;
  const fillA: React.CSSProperties = outline ? { fill: 'none', stroke: line, strokeWidth: 1.5 } : { fill: `url(#${g})` };
  const fill = (c: string): React.CSSProperties => (outline ? { fill: 'none', stroke: line, strokeWidth: 1.5 } : { fill: c });
  const stroke = (c: string, w: number): React.CSSProperties => ({ fill: 'none', stroke: outline ? line : c, strokeWidth: outline ? Math.min(w, 1.5) : w, strokeLinecap: 'round', strokeLinejoin: 'round' });

  let art: React.ReactNode = null;
  let spinPart: React.ReactNode = null;
  switch (kind) {
    case 'blob': {
      const d = closedPath(blobPoints(seed, 100, 100, 78));
      art = outline ? (
        <>
          <path d={d} style={fillA} vectorEffect="non-scaling-stroke" />
          <path d={closedPath(blobPoints(seed, 100, 100, 58))} style={fillA} vectorEffect="non-scaling-stroke" opacity="0.7" />
          <path d={closedPath(blobPoints(seed, 100, 100, 36))} style={fillA} vectorEffect="non-scaling-stroke" opacity="0.45" />
        </>
      ) : (
        <>
          <path d={d} style={fillA} />
        </>
      );
      break;
    }
    case 'rings':
      art = (
        <>
          {[86, 70, 54, 38].map((r, i) => (
            <circle key={r} cx="100" cy="100" r={r} style={{ ...stroke(i % 2 ? mid : hi, 7 - i), opacity: outline ? 1 - i * 0.12 : 0.55 + i * 0.15 }} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />
          ))}
          <circle cx="100" cy="100" r="18" style={outline ? stroke(line, 1.5) : { fill: hi }} />
        </>
      );
      break;
    case 'arch':
      art = (
        <>
          {[0, 1, 2, 3].map((i) => {
            const r = 84 - i * 18;
            return <path key={i} d={`M${100 - r} 178A${r} ${r} 0 0 1 ${100 + r} 178`} style={{ ...stroke([hi, mid, lo, hi][i], 16), strokeLinecap: 'butt' }} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />;
          })}
          <path d="M14 186H186" style={stroke(outline ? line : mid, 2)} />
        </>
      );
      break;
    case 'stack':
      art = (
        <>
          {[0, 1, 2].map((i) => (
            <rect key={i} x={30 + i * 22} y={66 - i * 22} width="96" height="96" rx="18" transform={`rotate(${-10 + i * 10} ${78 + i * 22} ${114 - i * 22})`} style={outline ? { ...fill(''), opacity: 0.55 + i * 0.22 } : { fill: [lo, mid, hi][i], stroke: 'var(--background)', strokeWidth: 2 }} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />
          ))}
        </>
      );
      break;
    case 'orbit': {
      const a = rand(seed, 3) * 360;
      art = (
        <>
          <circle cx="100" cy="100" r="80" style={{ ...stroke(mid, 1.5), strokeDasharray: outline ? undefined : '2 5' }} vectorEffect="non-scaling-stroke" />
          <circle cx="100" cy="100" r="44" style={fillA} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />
                  </>
      );
      spinPart = (
        <g transform={`rotate(${round(a, 1)} 100 100)`}>
          <circle cx="180" cy="100" r="10" style={outline ? stroke(line, 1.5) : { fill: hi, stroke: 'var(--background)', strokeWidth: 3 }} />
        </g>
      );
      break;
    }
    case 'wave':
      art = (
        <>
          {[0, 1, 2].map((i) => {
            const y = 70 + i * 30;
            return <path key={i} d={`M12 ${y}C42 ${y - 30} 58 ${y - 30} 78 ${y}S114 ${y + 30} 134 ${y}S170 ${y - 30} 188 ${y}`} style={stroke([hi, mid, lo][i], 14)} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />;
          })}
        </>
      );
      break;
    case 'flower': {
      const n = 6 + (seed % 3) * 2;
      art = (
        <>
          {Array.from({ length: n }, (_, i) => (
            <ellipse key={i} cx="100" cy="54" rx="22" ry="44" transform={`rotate(${round((i / n) * 360, 2)} 100 100)`} style={outline ? fill('') : { fill: i % 2 ? mid : hi, opacity: 0.92 }} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />
          ))}
          <circle cx="100" cy="100" r="20" style={outline ? stroke(line, 1.5) : { fill: lo, stroke: 'var(--background)', strokeWidth: 3 }} />
        </>
      );
      break;
    }
    case 'sphere': {
      const dots: React.ReactNode[] = [];
      const step = 8.5;
      const L = [-0.52, -0.6, 0.6];
      const ln = Math.hypot(...L);
      for (let y = -80; y <= 80; y += step) {
        for (let x = -80; x <= 80; x += step) {
          const nx = x / 82;
          const ny = y / 82;
          const q = 1 - nx * nx - ny * ny;
          if (q <= 0) continue;
          const nz = Math.sqrt(q);
          const lit = Math.max(0, (nx * L[0] + ny * L[1] + nz * L[2]) / ln);
          const r = round(0.5 + lit * 3.4, 2);
          dots.push(<circle key={`${x}-${y}`} cx={round(100 + x, 1)} cy={round(100 + y, 1)} r={r} />);
        }
      }
      art = <g style={outline ? { fill: 'none', stroke: line, strokeWidth: 0.8 } : { fill: tone === 'accent' ? hi : 'color-mix(in oklab, var(--foreground) 52%, var(--background))' }}>{dots}</g>;
      break;
    }
    case 'squiggle': {
      // a cursive curl: a line that moves forward while it circles, so it loops
      const loops = 3 + (seed % 2);
      const amp = 24 + rand(seed, 1) * 8;
      const pts: [number, number][] = Array.from({ length: 64 }, (_, i) => {
        const u = i / 63;
        const a = u * Math.PI * 2 * (loops + 0.5) - Math.PI / 2;
        return [round(18 + u * 150 + Math.cos(a) * 15, 2), round(100 + Math.sin(a) * amp * (0.75 + u * 0.4), 2)];
      });
      art = <path d={openPath(pts)} style={stroke(hi, 9)} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />;
      break;
    }
    case 'burst': {
      const n = 8 + (seed % 5) * 2;
      const inner = 0.36 + rand(seed, 5) * 0.16;
      const pts = Array.from({ length: n * 2 }, (_, i) => {
        const a = (i / (n * 2)) * Math.PI * 2 - Math.PI / 2;
        const r = i % 2 ? 88 * inner : 88;
        return `${round(100 + Math.cos(a) * r, 1)},${round(100 + Math.sin(a) * r, 1)}`;
      });
      art = (
        <>
          <polygon points={pts.join(' ')} style={{ ...fillA, strokeLinejoin: 'round' }} vectorEffect={outline ? 'non-scaling-stroke' : undefined} />
        </>
      );
      break;
    }
  }

  const m = animate !== 'none' ? MOTION[animate] : null;
  const time = round(duration ?? (kind === 'orbit' && animate === 'spin' ? 14 : m?.time ?? 0), 2);
  const css = m
    ? `${SCENE_CSS}@keyframes ${sid}a{${m.frames}}.${sid}:not([data-kv="off"]) .${sid}m{animation:${sid}a ${time}s ${m.ease} infinite}.${sid}m{transform-box:view-box;transform-origin:50% 50%}`
    : '';
  const spinWhole = !(kind === 'orbit' && animate === 'spin');
  const dim = typeof size === 'number' ? `${size}px` : size;

  return (
    <span ref={play.ref} aria-hidden data-kv={m ? play.state : undefined} className={cn(sid, 'inline-block aspect-square shrink-0', className)} style={{ width: dim }}>
      {css && <style dangerouslySetInnerHTML={{ __html: css }} />}
      <svg viewBox="0 0 200 200" className="block size-full overflow-visible">
        <defs>
          <radialGradient id={g} cx="0.32" cy="0.26" r="0.85">
            <stop offset="0" style={{ stopColor: mid }} />
            <stop offset="0.75" style={{ stopColor: hi }} />
          </radialGradient>
        </defs>
        <g className={spinWhole ? `${sid}m` : undefined}>
          {art}
          {spinPart && <g className={spinWhole ? undefined : `${sid}m`}>{spinPart}</g>}
        </g>
      </svg>
    </span>
  );
}

Frequent questions

How do I install Shapes?

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

Which variants does Shapes have?

4 variants, Blobs, Rings and orbits, Geometric set and Composition.

Can an AI assistant build with Shapes?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Shapes with aiw_components and install it with aiw_add @ai-website/shapes.

Often searched

Jump to

Copied to the clipboard