Bloom Footer
A landing page footer in the usual shape (brand, link columns, legal line), with the company name set huge along the bottom edge. The letters are filled with a garden of outlined flowers, and slow waves of colour roll across them.
Move the pointer over the wordmark and the flowers under it swell a little. Click and a ripple of new colour spreads out from that point. Use Full screen to see it at the bottom of a real page, edge to edge.
How it is drawn
The wordmark is a single <canvas>. The text is painted once into an
offscreen mask, and flowers are scattered over every covered pixel in three
layers: large leaves and blossoms first, then mid-size flowers, then small buds
on top. Each frame draws the flowers and keeps only the pixels inside the
letters with destination-in.
- Flowers are paths, not images. Six kinds (petal, daisy, star, scallop,
bud, leaf) are built once as
Path2Din unit space. Drawing one is a transform plus a fill and stroke per part, so a few hundred stay cheap. - The layout is seeded from the text. The same word always grows the same garden, and resizing replants it at the new scale.
- Colour changes are waves. A diagonal phase moves each flower to the next palette every few seconds. At the moment of the swap it shrinks to 70% and re-blooms with an ease-out, so the change reads as growth rather than a flicker.
Palettes
Palettes accept any CSS colour, including var() and color-mix(). They are
resolved through a hidden probe element and re-read when the theme class
changes, so the Mono set follows the site tokens in light and dark.
Details
- The canvas is
aria-hidden; the text is repeated in ansr-onlyspan. - The loop only runs while the wordmark is on screen and the tab is visible.
- With reduced motion it draws a single still frame and ignores the pointer.
- The block measures its container, not the viewport, so it fits the preview column, a phone, and a full page alike.
Usage
The footer is a server component; only the wordmark ships JavaScript.
import {
FlowerIcon,
GithubLogoIcon,
LinkedinLogoIcon,
XLogoIcon,
} from "@phosphor-icons/react/dist/ssr";
import {
BLOOM_PALETTES,
BloomWordmark,
type BloomPalette,
} from "@/components/blocks/footer/bloom-wordmark";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type FooterLink = { label: string; href: string };
type FooterColumn = { title: string; links: FooterLink[] };
type SocialLink = { label: string; href: string; icon: "x" | "github" | "linkedin" };
const SOCIAL_ICONS = {
x: XLogoIcon,
github: GithubLogoIcon,
linkedin: LinkedinLogoIcon,
};
const DEFAULT_COLUMNS: FooterColumn[] = [
{
title: "Product",
links: [
{ label: "Features", href: "#" },
{ label: "Pricing", href: "#" },
{ label: "Changelog", href: "#" },
{ label: "Integrations", href: "#" },
],
},
{
title: "Company",
links: [
{ label: "About", href: "#" },
{ label: "Careers", href: "#" },
{ label: "Blog", href: "#" },
{ label: "Contact", href: "#" },
],
},
{
title: "Resources",
links: [
{ label: "Docs", href: "#" },
{ label: "Guides", href: "#" },
{ label: "Status", href: "#" },
{ label: "Privacy", href: "#" },
],
},
];
const DEFAULT_SOCIALS: SocialLink[] = [
{ label: "X", href: "#", icon: "x" },
{ label: "GitHub", href: "#", icon: "github" },
{ label: "LinkedIn", href: "#", icon: "linkedin" },
];
export function BloomFooter({
name = "Bloom",
tagline = "Tools for teams who like their work to grow a little every day.",
cta = { label: "Start for free", href: "#" },
columns = DEFAULT_COLUMNS,
socials = DEFAULT_SOCIALS,
wordmark = name,
palettes = BLOOM_PALETTES,
className,
}: {
name?: string;
tagline?: string;
cta?: FooterLink | null;
columns?: FooterColumn[];
socials?: SocialLink[];
/** The text drawn in flowers along the bottom. Short words work best. */
wordmark?: string;
palettes?: BloomPalette[];
className?: string;
}) {
return (
<footer
className={cn("@container overflow-hidden bg-background text-foreground", className)}
>
<div className="px-6 pt-12 @3xl:px-10 @3xl:pt-16">
<div className="grid gap-10 @3xl:grid-cols-[1.3fr_2fr]">
<div className="flex max-w-xs flex-col items-start gap-4">
<a
href="#"
className="flex items-center gap-2 rounded-full text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground">
<FlowerIcon weight="fill" className="size-4" />
</span>
{name}
</a>
<p className="text-sm leading-relaxed text-pretty text-muted-foreground">
{tagline}
</p>
{cta && (
<Button size="sm" nativeButton={false} render={<a href={cta.href} />}>
{cta.label}
</Button>
)}
</div>
<nav
aria-label="Footer"
className="grid grid-cols-2 gap-x-6 gap-y-8 @md:grid-cols-3"
>
{columns.map((column) => (
<div key={column.title}>
<h2 className="text-xs font-medium text-foreground">{column.title}</h2>
<ul className="mt-3 flex flex-col">
{column.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
className="-mx-1 inline-flex h-8 items-center rounded-md px-1 text-sm text-muted-foreground transition-colors duration-150 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</div>
<div className="mt-12 flex flex-col-reverse items-start gap-3 border-t border-border pt-4 @md:flex-row @md:items-center @md:justify-between">
<p className="text-xs text-muted-foreground tabular-nums">
© {new Date().getFullYear()} {name}. All rights reserved.
</p>
<ul className="-mx-2.5 flex items-center">
{socials.map((social) => {
const Icon = SOCIAL_ICONS[social.icon];
return (
<li key={social.label}>
<a
href={social.href}
aria-label={social.label}
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
<Icon className="size-4" />
</a>
</li>
);
})}
</ul>
</div>
</div>
<BloomWordmark text={wordmark} palettes={palettes} className="mt-4 px-3 @3xl:px-6" />
</footer>
);
}"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
/** One colour set: an outline ink and the fills flowers pick from. */
export type BloomPalette = { ink: string; fills: string[] };
/** Cool, warm, and violet sets. Waves of colour move between them. */
export const BLOOM_PALETTES: BloomPalette[] = [
{
ink: "oklch(0.36 0.15 285)",
fills: [
"oklch(0.76 0.11 275)",
"oklch(0.8 0.1 185)",
"oklch(0.91 0.04 210)",
"oklch(0.68 0.13 280)",
"oklch(0.84 0.08 240)",
"oklch(0.72 0.16 25)",
],
},
{
ink: "oklch(0.38 0.13 335)",
fills: [
"oklch(0.72 0.16 28)",
"oklch(0.84 0.09 55)",
"oklch(0.75 0.15 355)",
"oklch(0.82 0.13 75)",
"oklch(0.84 0.14 125)",
"oklch(0.66 0.14 285)",
],
},
{
ink: "oklch(0.32 0.14 290)",
fills: [
"oklch(0.64 0.17 292)",
"oklch(0.72 0.17 325)",
"oklch(0.8 0.1 300)",
"oklch(0.82 0.12 160)",
"oklch(0.74 0.16 355)",
"oklch(0.85 0.1 90)",
],
},
];
const mix = (amount: number) =>
"color-mix(in oklch, var(--foreground) " + amount + "%, var(--background))";
/** Theme-aware greys built from the site tokens. */
export const MONO_PALETTES: BloomPalette[] = [
{ ink: "var(--foreground)", fills: [mix(6), mix(14), mix(24), mix(10)] },
{ ink: "var(--foreground)", fills: [mix(32), mix(18), mix(8), mix(44)] },
];
type Kind = "petal" | "daisy" | "star" | "scallop" | "bud" | "leaf";
type Part = { path: Path2D; offset: number; ink?: boolean };
type Flower = {
x: number;
y: number;
r: number;
rot: number;
spin: number;
seed: number;
color: number;
parts: Part[];
};
type Ripple = { x: number; y: number; t0: number };
const TAU = Math.PI * 2;
const WAVE_PERIOD = 4.5; // seconds between colour changes at one spot
const RIPPLE_SPEED = 900; // px per second
const POP = 0.35; // seconds a flower takes to re-bloom after a swap
function random(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash(text: string) {
let h = 2166136261;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
return h >>> 0;
}
function ellipse(path: Path2D, x: number, y: number, rx: number, ry: number, a: number) {
path.moveTo(x + Math.cos(a) * rx, y + Math.sin(a) * rx);
path.ellipse(x, y, rx, ry, a, 0, TAU);
}
function circle(x: number, y: number, r: number) {
const path = new Path2D();
path.arc(x, y, r, 0, TAU);
return path;
}
/** Builds a flower in unit space (radius 1), as layered fill-and-stroke parts. */
function buildParts(kind: Kind, rand: () => number): Part[] {
const parts: Part[] = [];
if (kind === "petal") {
const n = 5 + Math.floor(rand() * 3);
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
const petal = new Path2D();
ellipse(petal, Math.cos(a) * 0.5, Math.sin(a) * 0.5, 0.5, 0.36, a);
parts.push({ path: petal, offset: 0 });
}
parts.push({ path: circle(0, 0, 0.3), offset: 2 });
parts.push({ path: circle(0, 0, 0.1), offset: 0, ink: true });
} else if (kind === "daisy") {
const n = 12 + Math.floor(rand() * 6);
const back = new Path2D();
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
ellipse(back, Math.cos(a) * 0.56, Math.sin(a) * 0.56, 0.44, 0.1, a);
}
parts.push({ path: back, offset: 0 });
parts.push({ path: circle(0, 0, 0.26), offset: 3 });
} else if (kind === "star") {
const n = 8 + Math.floor(rand() * 5);
const star = new Path2D();
for (let i = 0; i <= n * 2; i++) {
const a = (i / (n * 2)) * TAU;
const d = i % 2 === 0 ? 1 : 0.42;
if (i === 0) star.moveTo(Math.cos(a) * d, Math.sin(a) * d);
else star.lineTo(Math.cos(a) * d, Math.sin(a) * d);
}
star.closePath();
parts.push({ path: star, offset: 0 });
parts.push({ path: circle(0, 0, 0.28), offset: 2 });
} else if (kind === "scallop") {
const n = 10 + Math.floor(rand() * 4);
const edge = new Path2D();
for (let i = 0; i < n; i++) {
const a = (i / n) * TAU;
const b = ((i + 1) / n) * TAU;
const mid = (a + b) / 2;
if (i === 0) edge.moveTo(Math.cos(a) * 0.86, Math.sin(a) * 0.86);
edge.quadraticCurveTo(
Math.cos(mid) * 1.12,
Math.sin(mid) * 1.12,
Math.cos(b) * 0.86,
Math.sin(b) * 0.86,
);
}
edge.closePath();
parts.push({ path: edge, offset: 0 });
parts.push({ path: circle(0, 0, 0.58), offset: 1 });
parts.push({ path: circle(0, 0, 0.26), offset: 3 });
} else if (kind === "bud") {
parts.push({ path: circle(0, 0, 1), offset: 0 });
const clover = new Path2D();
for (let i = 0; i < 4; i++) {
const a = (i / 4) * TAU + Math.PI / 4;
clover.moveTo(Math.cos(a) * 0.3 + 0.18, Math.sin(a) * 0.3);
clover.arc(Math.cos(a) * 0.3, Math.sin(a) * 0.3, 0.18, 0, TAU);
}
parts.push({ path: clover, offset: 2 });
} else {
const leaf = new Path2D();
leaf.moveTo(-1, 0);
leaf.quadraticCurveTo(0, -0.62, 1, 0);
leaf.quadraticCurveTo(0, 0.62, -1, 0);
leaf.closePath();
parts.push({ path: leaf, offset: 4 });
const rib = new Path2D();
rib.moveTo(-0.8, 0);
rib.lineTo(0.8, 0);
parts.push({ path: rib, offset: 0, ink: true });
}
return parts;
}
/** Scatters flowers over every covered pixel of the mask, in two layers. */
function plant(alpha: Uint8ClampedArray, pw: number, ph: number, scale: number, height: number, seed: number) {
const rand = random(seed);
const cell = Math.max(10, height / 5.5);
const width = pw / scale;
const covered = (x: number, y: number) => {
const px = Math.round(x * scale);
const py = Math.round(y * scale);
if (px < 0 || py < 0 || px >= pw || py >= ph) return false;
return alpha[(py * pw + px) * 4 + 3] > 20;
};
const touches = (x: number, y: number, r: number) =>
covered(x, y) ||
covered(x + r * 0.6, y) ||
covered(x - r * 0.6, y) ||
covered(x, y + r * 0.6) ||
covered(x, y - r * 0.6);
const flowers: Flower[] = [];
const layers: { size: number; step: number; kinds: Kind[] }[] = [
{ size: 1.1, step: 1.25, kinds: ["leaf", "scallop", "petal", "leaf"] },
{ size: 0.8, step: 0.95, kinds: ["petal", "daisy", "star", "scallop", "bud"] },
{ size: 0.42, step: 1.1, kinds: ["bud", "star", "petal"] },
];
for (const layer of layers) {
const step = cell * layer.step;
for (let gy = -step / 2; gy < height + step; gy += step) {
for (let gx = -step / 2; gx < width + step; gx += step) {
const x = gx + (rand() - 0.5) * step;
const y = gy + (rand() - 0.5) * step;
const r = cell * layer.size * (0.75 + rand() * 0.5);
if (!touches(x, y, r)) continue;
const kind = layer.kinds[Math.floor(rand() * layer.kinds.length)];
flowers.push({
x,
y,
r: kind === "leaf" ? r * 1.4 : r,
rot: rand() * TAU,
spin: (rand() - 0.5) * (kind === "leaf" ? 0.05 : 0.25),
seed: rand(),
color: Math.floor(rand() * 6),
parts: buildParts(kind, rand),
});
}
}
}
return flowers;
}
function resolveColors(probe: HTMLElement, palettes: BloomPalette[]) {
const read = (value: string) => {
probe.style.color = value;
return getComputedStyle(probe).color;
};
return palettes.map((p) => ({ ink: read(p.ink), fills: p.fills.map(read) }));
}
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
export function BloomWordmark({
text = "Bloom",
palettes = BLOOM_PALETTES,
className,
}: {
text?: string;
/** Colour sets the waves cycle through. Accepts any CSS colour, including var(). */
palettes?: BloomPalette[];
className?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const probeRef = useRef<HTMLSpanElement>(null);
const reduceMotion = useReducedMotion();
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const probe = probeRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !probe || !ctx) return;
const mask = document.createElement("canvas");
const maskCtx = mask.getContext("2d", { willReadFrequently: true });
if (!maskCtx) return;
let width = 0;
let height = 0;
let dpr = 1;
let flowers: Flower[] = [];
let colors = resolveColors(probe, palettes);
let ripples: Ripple[] = [];
let settled = 0; // ripples that have finished crossing the whole word
let frame = 0;
let visible = false;
let start = performance.now();
const pointer = { x: 0, y: 0, tx: 0, ty: 0, on: 0, target: 0 };
const layout = () => {
const w = canvas.clientWidth;
if (!w) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
const family = getComputedStyle(root).fontFamily;
const font = (size: number) => "900 " + size + "px " + family;
maskCtx.font = font(100);
maskCtx.letterSpacing = "-5px";
const size = (100 * w * 0.98) / maskCtx.measureText(text).width;
maskCtx.font = font(size);
maskCtx.letterSpacing = -size * 0.05 + "px";
const metrics = maskCtx.measureText(text);
const pad = size * 0.04;
const ascent = metrics.actualBoundingBoxAscent;
width = w;
height = Math.ceil(ascent + metrics.actualBoundingBoxDescent + pad * 2);
for (const c of [mask, canvas]) {
c.width = Math.round(width * dpr);
c.height = Math.round(height * dpr);
}
canvas.style.height = height + "px";
maskCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
maskCtx.font = font(size);
maskCtx.letterSpacing = -size * 0.05 + "px";
maskCtx.textAlign = "center";
maskCtx.textBaseline = "alphabetic";
maskCtx.fillStyle = "#000";
maskCtx.fillText(text, width / 2, pad + ascent);
const { data } = maskCtx.getImageData(0, 0, mask.width, mask.height);
flowers = plant(data, mask.width, mask.height, dpr, height, hash(text));
draw(performance.now());
};
const draw = (now: number) => {
const t = reduceMotion ? 0 : (now - start) / 1000;
pointer.on += (pointer.target - pointer.on) * 0.12;
pointer.x += (pointer.tx - pointer.x) * 0.2;
pointer.y += (pointer.ty - pointer.y) * 0.2;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = colors[0].ink;
ctx.fillRect(0, 0, width, height);
ctx.lineJoin = "round";
ctx.lineCap = "round";
const reach = height * 0.45;
for (const f of flowers) {
// A diagonal wave moves each flower to the next palette.
const wave = t / WAVE_PERIOD - (f.x / width) * 1.3 - (f.y / height) * 0.25 - f.seed * 0.12;
let step = Math.floor(wave) + settled;
let since = (wave - Math.floor(wave)) * WAVE_PERIOD;
for (const rp of ripples) {
const hit = rp.t0 + Math.hypot(f.x - rp.x, f.y - rp.y) / RIPPLE_SPEED;
if (t >= hit) {
step += 1;
since = Math.min(since, t - hit);
}
}
const palette = colors[((step % colors.length) + colors.length) % colors.length];
const bloom = reduceMotion || since >= POP ? 1 : easeOut(since / POP);
let near = 0;
if (pointer.on > 0.01) {
const d = Math.hypot(f.x - pointer.x, f.y - pointer.y);
near = Math.max(0, 1 - d / reach) ** 2 * pointer.on;
}
const r = f.r * (0.7 + 0.3 * bloom) * (1 + near * 0.18);
const rot = f.rot + t * f.spin + (1 - bloom) * 0.6 + near * 0.5 + Math.sin(t * 0.8 + f.seed * TAU) * 0.05;
const cos = Math.cos(rot) * r;
const sin = Math.sin(rot) * r;
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, f.x * dpr, f.y * dpr);
ctx.lineWidth = Math.max(1, height / 160) / r;
ctx.strokeStyle = palette.ink;
for (const part of f.parts) {
ctx.fillStyle = part.ink
? palette.ink
: palette.fills[(f.color + part.offset) % palette.fills.length];
ctx.fill(part.path);
ctx.stroke(part.path);
}
}
// Keep only the pixels inside the letters.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(mask, 0, 0);
// Fold finished ripples into a counter so the list stays short.
const diagonal = Math.hypot(width, height) / RIPPLE_SPEED + POP;
const done = ripples.filter((rp) => t - rp.t0 > diagonal).length;
if (done) {
settled += done;
ripples = ripples.filter((rp) => t - rp.t0 <= diagonal);
}
};
const loop = (now: number) => {
draw(now);
frame = requestAnimationFrame(loop);
};
const play = () => {
cancelAnimationFrame(frame);
if (visible && !reduceMotion && !document.hidden) frame = requestAnimationFrame(loop);
};
const local = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
return {
x: ((e.clientX - rect.left) * width) / rect.width,
y: ((e.clientY - rect.top) * height) / rect.height,
};
};
const onMove = (e: PointerEvent) => {
const p = local(e);
if (pointer.target === 0) {
pointer.x = p.x;
pointer.y = p.y;
}
pointer.tx = p.x;
pointer.ty = p.y;
pointer.target = 1;
};
const onLeave = () => {
pointer.target = 0;
};
const onDown = (e: PointerEvent) => {
const p = local(e);
ripples.push({ ...p, t0: (performance.now() - start) / 1000 });
};
const resize = new ResizeObserver(layout);
const seen = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
play();
});
const theme = new MutationObserver(() => {
colors = resolveColors(probe, palettes);
if (reduceMotion || !visible) draw(performance.now());
});
let cancelled = false;
const family = getComputedStyle(root).fontFamily;
document.fonts
.load("900 100px " + family, text)
.catch(() => undefined)
.then(() => {
if (cancelled) return;
start = performance.now();
layout();
resize.observe(root);
seen.observe(root);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
});
const onVisibility = () => play();
document.addEventListener("visibilitychange", onVisibility);
if (!reduceMotion) {
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerleave", onLeave);
canvas.addEventListener("pointerdown", onDown);
}
return () => {
cancelled = true;
cancelAnimationFrame(frame);
resize.disconnect();
seen.disconnect();
theme.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("pointerdown", onDown);
};
}, [text, palettes, reduceMotion]);
return (
<div ref={rootRef} className={cn("relative w-full font-sans", className)}>
<span ref={probeRef} aria-hidden="true" className="hidden" />
<canvas
ref={canvasRef}
aria-hidden="true"
width={1000}
height={240}
className="block h-auto w-full touch-pan-y"
/>
<span className="sr-only">{text}</span>
</div>
);
}