Innovations

Blob Tiles (Category Strip)

A row of blob-masked photographs, each with an offset backing blob drifting at its own depth and a small disc in the corner. Four across on desktop, two on mobile.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Blob Tiles — a row of blob-masked photographs, each with an offset backing
 * blob that drifts at its own depth and a small corner disc.
 *
 * The row is the cheapest way to make a category strip feel bespoke: the same
 * four photographs in square cards read as a template, and the only difference
 * is the mask and the offset shape behind it.
 *
 * Each tile's backing blob takes a DIFFERENT depth, increasing left to right.
 * Uniform depth makes the whole row slide together, which reads as a bug rather
 * than as parallax.
 */

export interface BlobTile {
  label: string;
  imageSrc: string;
  imageAlt?: string;
  href?: string;
}

export interface BlobTilesProps {
  tiles?: BlobTile[];
  accentSoft?: string;
  accent?: string;
  ink?: string;
  /** The small disc in the corner of each tile. "" removes it. */
  cornerMark?: string;
}

const DEMO: BlobTile[] = [
  { label: "Preventive", imageSrc: "/heroes/quantum-soft/main.webp" },
  { label: "Restorative", imageSrc: "/heroes/quantum-soft/second.webp" },
  { label: "Cosmetic", imageSrc: "/heroes/blob-portrait/woman.webp" },
  { label: "Invisalign®", imageSrc: "/heroes/geniuscoaching/3VQ-Image.webp" },
];

export default function BlobTiles({
  tiles = DEMO,
  accentSoft = "#fbd9dd",
  accent = "#d26575",
  ink = "#33323e",
  cornerMark = "+",
}: BlobTilesProps) {
  const rootRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
    const blobs = Array.from(root.querySelectorAll<HTMLElement>("[data-depth]"));
    let queued = false;
    const move = (e: PointerEvent) => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        const x = (e.clientX / window.innerWidth - 0.5) * 2;
        const y = (e.clientY / window.innerHeight - 0.5) * 2;
        for (const b of blobs) {
          const d = parseFloat(b.dataset.depth || "1");
          b.style.setProperty("--tx", (x * d * 9).toFixed(1) + "px");
          b.style.setProperty("--ty", (y * d * 9).toFixed(1) + "px");
        }
      });
    };
    window.addEventListener("pointermove", move, { passive: true });
    return () => window.removeEventListener("pointermove", move);
  }, []);

  const css = `
    .bt { padding: 20px 20px 76px; font-family: 'Archivo', system-ui, sans-serif; }
    @media (min-width: 768px) { .bt { padding: 20px 28px 120px; } }
    .bt, .bt * { box-sizing: border-box; }
    .bt-in { max-width: 1180px; margin: 0 auto; display: grid;
      grid-template-columns: 1fr 1fr; gap: 32px; }
    @media (min-width: 900px) { .bt-in { grid-template-columns: repeat(4, 1fr); gap: 52px; } }

    .bt-tile { text-align: center; text-decoration: none; display: block; }
    .bt-box { position: relative; width: 100%; aspect-ratio: 1; }
    /* Backing blob: offset up and to the LEFT of the photo, never centred behind
       it — a centred one just looks like a badly aligned frame. */
    .bt-back { position: absolute; left: -14%; top: 6%; width: 86%; height: 86%;
      background: ${accentSoft};
      border-radius: 58% 42% 46% 54% / 52% 48% 52% 48%;
      transform: translate3d(var(--tx,0px), var(--ty,0px), 0);
      will-change: transform; pointer-events: none; }
    .bt-frame { position: absolute; inset: 0; overflow: hidden;
      border-radius: 62% 38% 44% 56% / 52% 58% 42% 48%; }
    .bt-frame img { width: 100%; height: 100%; object-fit: cover; display: block;
      transition: transform 1.1s cubic-bezier(.16,1,.3,1); }
    .bt-tile:hover .bt-frame img { transform: scale(1.1); }
    .bt-mark { position: absolute; right: 6%; bottom: 6%; width: 46px; height: 46px;
      border-radius: 50%; background: rgba(255,255,255,.95); color: ${accent};
      font-size: 24px; display: flex; align-items: center; justify-content: center;
      box-shadow: 0 10px 26px rgba(0,0,0,.14); }
    .bt-label { margin: 22px 0 0; font-size: 18px; color: ${ink};
      text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
    @media (min-width: 768px) { .bt-label { font-size: 22px; } }

    @media (prefers-reduced-motion: reduce) { .bt-back { transform: none !important; } }
  `;

  return (
    <section className="bt" ref={rootRef}>
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" />
      <style>{css}</style>
      <div className="bt-in">
        {tiles.map((t, i) => {
          const Tag = (t.href ? "a" : "div") as any;
          return (
            <Tag key={t.label + i} className="bt-tile" href={t.href}>
              <div className="bt-box">
                {/* Depth increases across the row so the tiles separate. */}
                <div className="bt-back" data-depth={(1.2 + i * 0.3).toFixed(1)} aria-hidden="true" />
                <div className="bt-frame">
                  <img src={t.imageSrc} alt={t.imageAlt ?? t.label} loading="lazy" decoding="async" />
                </div>
                {cornerMark && <span className="bt-mark" aria-hidden="true">{cornerMark}</span>}
              </div>
              <p className="bt-label">{t.label}</p>
            </Tag>
          );
        })}
      </div>
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add blob-tiles

Where to use it

Use as a category or service strip directly under a hero or a split band. It is the cheapest way to make a four-up photo row feel bespoke: the same four photographs in square cards read as a template, and the only thing that changes that is the mask and the shape offset behind it. In Astro: --- import BlobTiles from '../components/innovations/content/blob-tiles'; --- <BlobTiles client:visible /> EACH TILE'S BACKING BLOB TAKES A DIFFERENT DEPTH, increasing left to right. Uniform depth makes the whole row slide together under the cursor, which reads as a rendering bug rather than as parallax. Photographs want a clear subject and a simple background: the mask crops hard and asymmetrically, so anything with detail near the edges loses it. Portrait or square crops survive better than landscape. Four tiles is the design. Three leaves a hole in the desktop grid and five wraps to a second row where the mobile two-up leaves one stranded. If you have five categories, either promote one or use a different section. Pass href on a tile to make it a link; without one it renders as a plain div, which is correct when the strip is decorative rather than navigational. cornerMark="" removes the disc.