Innovations

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-split

Where 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.