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
Variants
Details
What Shapes brings along, read from the registry and the source.
- Category
- Patterns and shapes
- Area
- Visuals
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ShapeProps interface from components/ui/shapes.tsx, with the comments from the code.
kindoptionalShapeKindblobringsarchstackorbitwaveflowerspheresquiggleburst
seedoptionalnumbertoneoptionalShapeToneaccentmutedoutline
accent (default) fills with the accent color, muted with a quiet neutral, outline draws only lines
sizeoptionalnumber | stringWidth and height, a number in pixels or any CSS length. Default 160.
animateoptionalShapeMotionnonefloatspinbreathe
float, spin and breathe are slow loops, default none
durationoptionalnumberSeconds 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
'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.