content /
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-tilesWhere 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.