content /
Blob Split (Photo + Icon List)
Two-column band with a blob-masked photograph, an offset backing blob that drifts with the cursor, an optional badge hanging off the corner, and a two-up icon+label list. Reversible, so consecutive sections can alternate sides.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
/**
* Blob Split — a two-column band with a blob-masked photograph, an offset
* backing blob that drifts with the cursor, and an optional badge hanging off
* the corner.
*
* THE BLOBS ARE FOUR-VALUE ELLIPTICAL RADII, never `border-radius: 50%`. That is
* the entire difference between "organic" and "a circle": each corner gets its
* own horizontal and vertical radius, so the outline is asymmetric and reads as
* drawn rather than generated.
*
* The backing blob sits BEHIND the photo and moves at a fraction of the pointer,
* which is what gives the pair depth. One listener drives every blob on the
* page — depth is a data attribute, so adding another costs no JavaScript.
*/
export interface BlobSplitProps {
kicker?: string;
headline?: string;
body?: string;
/** Short icon+label pairs. Six reads best; more turns into a list. */
items?: string[];
imageSrc?: string;
imageAlt?: string;
/** Hangs off the photo's corner. Omit for none — do not invent a figure. */
badge?: { top: string; bottom: string } | null;
cta?: { label: string; href: string } | null;
/** Photo on the right (default) or the left. */
reverse?: boolean;
accent?: string;
accentSoft?: string;
ink?: string;
inkSoft?: string;
}
export default function BlobSplit({
kicker = "Dental for the",
headline = "Whole Family",
body =
"Exceptional care starts with putting people first. Advanced technology and a comfort-focused team mean everything your family needs happens under one roof.",
items = [
"Comfort-focused care", "Same-day emergencies", "Digital technology",
"Care under one roof", "Flexible financing", "In-house membership",
],
imageSrc = "/heroes/dating-cards/couple.webp",
imageAlt = "Two people smiling together",
badge = { top: "$99", bottom: "First Visit" },
cta = null,
reverse = false,
accent = "#d26575",
accentSoft = "#fbd9dd",
ink = "#33323e",
inkSoft = "#5b5866",
}: BlobSplitProps) {
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]"));
if (!blobs.length) return;
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 = `
.bs { padding: 76px 20px; font-family: 'Archivo', system-ui, sans-serif; color: ${inkSoft}; }
@media (min-width: 768px) { .bs { padding: 120px 28px; } }
.bs, .bs * { box-sizing: border-box; }
.bs-in { max-width: 1180px; margin: 0 auto; display: grid; gap: 44px; align-items: center; }
@media (min-width: 768px) { .bs-in { grid-template-columns: 1fr 1fr; gap: 64px; } }
.bs-d { text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
.bs-kick { margin: 0; font-size: clamp(26px,3.4vw,38px); font-weight: 500;
color: ${ink}; text-transform: uppercase; letter-spacing: -.02em; }
.bs-h { margin: 0 0 24px; font-size: clamp(46px,7vw,78px); line-height: .95; color: ${accent}; }
.bs-body { margin: 0 0 32px; max-width: 520px; font-size: 16px; line-height: 1.75; text-wrap: pretty; }
.bs-rule { height: 1px; background: color-mix(in srgb, ${accent} 30%, transparent); margin-bottom: 32px; }
.bs-items { display: grid; gap: 20px 44px; max-width: 520px; }
@media (min-width: 560px) { .bs-items { grid-template-columns: 1fr 1fr; } }
.bs-item { display: flex; align-items: center; gap: 14px; font-size: 15px; }
.bs-mark { width: 38px; height: 38px; flex: 0 0 38px; border-radius: 10px;
border: 1.5px solid color-mix(in srgb, ${accent} 55%, transparent);
display: flex; align-items: center; justify-content: center; color: ${accent}; font-size: 16px; }
.bs-stage { position: relative; display: flex; align-items: center;
justify-content: center; height: 380px; }
@media (min-width: 768px) { .bs-stage { height: 480px; } }
/* FOUR-VALUE RADII. A plain 50% here would make the whole thing read as
stock; the asymmetry is the design. */
.bs-back { position: absolute; width: 300px; height: 300px; bottom: 36px; left: 8%;
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; }
@media (min-width: 768px) { .bs-back { width: 330px; height: 330px; } }
.bs-frame { position: relative; width: 300px; height: 300px; overflow: hidden;
border-radius: 62% 38% 44% 56% / 52% 58% 42% 48%; }
@media (min-width: 768px) { .bs-frame { width: 400px; height: 400px; } }
.bs-frame img { width: 100%; height: 100%; object-fit: cover; display: block;
transition: transform 1.1s cubic-bezier(.16,1,.3,1); }
.bs-frame:hover img { transform: scale(1.12); }
.bs-badge { position: absolute; bottom: 42px; left: 2%;
transform: translate3d(var(--tx,0px), var(--ty,0px), 0); will-change: transform; }
.bs-badge span { display: flex; flex-direction: column; align-items: center;
justify-content: center; width: 104px; height: 104px; background: ${accent};
color: #fff; text-align: center; line-height: 1.15; font-size: 15px;
border-radius: 58% 42% 50% 50% / 46% 54% 46% 54%;
box-shadow: 0 18px 40px color-mix(in srgb, ${accent} 40%, transparent); }
@media (min-width: 768px) { .bs-badge span { width: 132px; height: 132px; font-size: 18px; } }
.bs-badge b { font-size: 1.35em; }
.bs-cta { display: inline-flex; align-items: center; gap: 12px; margin-top: 30px;
background: ${accent}; color: #fff; text-decoration: none; border-radius: 6px;
padding: 15px 28px; font-size: 15px; font-weight: 600; letter-spacing: .03em;
transition: transform .3s ease; }
.bs-cta:hover { transform: translateY(-3px); color: #fff; }
@media (prefers-reduced-motion: reduce) {
.bs-back, .bs-badge { transform: none !important; }
}
`;
const media = (
<div className="bs-stage">
<div className="bs-back" data-depth="1.2" aria-hidden="true" />
<div className="bs-frame">
<img src={imageSrc} alt={imageAlt} loading="lazy" decoding="async" />
</div>
{badge && (
<div className="bs-badge" data-depth="0.6">
<span><b>{badge.top}</b>{badge.bottom}</span>
</div>
)}
</div>
);
const copy = (
<div>
{kicker && <p className="bs-kick">{kicker}</p>}
{headline && <h2 className="bs-d bs-h">{headline}</h2>}
{body && <p className="bs-body">{body}</p>}
{items.length > 0 && <>
<div className="bs-rule" />
<div className="bs-items">
{items.map((t) => (
<div className="bs-item" key={t}>
<span className="bs-mark" aria-hidden="true">✦</span>
<span>{t}</span>
</div>
))}
</div>
</>}
{cta && <a className="bs-cta" href={cta.href}>{cta.label} <span aria-hidden="true">→</span></a>}
</div>
);
return (
<section className="bs" 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="bs-in">
{reverse ? <>{media}{copy}</> : <>{copy}{media}</>}
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add blob-splitWhere to use it
Use as the "who we are" or "what you get" band under a hero. It carries a decent amount of copy without turning into a wall, because the icon list breaks the bottom half into scannable pairs.
In Astro:
---
import BlobSplit from '../components/innovations/content/blob-split';
---
<BlobSplit client:visible />
client:visible is enough — the only script is the cursor parallax.
THE BLOBS ARE FOUR-VALUE ELLIPTICAL RADII, never border-radius: 50%. Each corner gets its own horizontal and vertical radius (58% 42% 46% 54% / 52% 48% 52% 48%), which is the entire difference between "organic" and "a circle". If you generate new shapes, keep them asymmetric and keep the photo's mask different from the backing blob's — matching them makes the pair look like a misaligned frame.
The backing blob sits BEHIND the photo, offset down and left, and moves at a fraction of the pointer. That offset is what gives the pair depth; centred behind the photo it just looks wrong.
Set reverse to put the photo on the left. Stack two of these with alternating reverse for a classic zig-zag, but no more than two — beyond that the rhythm becomes the design and the content stops being read.
items is six short labels at most. Longer than about four words each and the two-up grid breaks badly. badge takes {top, bottom} or null; do not invent a figure to fill it.