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 Path2D in 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.

Petal

Details

  • The canvas is aria-hidden; the text is repeated in an sr-only span.
  • 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.

components/blocks/footer/bloom-footer.tsx
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>
  );
}

Search concepts

Search for a command to run...