content /
Pill Band (Lozenge Flood + Steps or Offer)
A flooded section rounded into a wide lozenge, with a cut-out figure standing on its lower edge and either numbered steps or an offer block alongside. Floats free of the page edges, so it needs no mask and no wave.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
/**
* Pill Band — a flooded section rounded into a wide lozenge, with a cut-out
* figure standing on its lower edge and either numbered steps or an offer
* alongside.
*
* THE SHAPE IS ONE BORDER-RADIUS, and the trick is the SLASH:
* border-radius: 46% 46% 46% 46% / 6% 6% 6% 6%
* Horizontal radii of 46% with vertical radii of 6% give a very wide, very
* shallow curve on all four corners — a lozenge rather than a rounded
* rectangle. Write it as a single value and you get a circle instead.
*
* Because the band floats (it does not touch the viewport edges), it needs no
* mask and no wave: the radius alone separates it from the page.
*/
export interface PillStep {
n: string;
title: string;
body: string;
}
export interface PillBandProps {
kicker?: string;
headline?: string;
/** Numbered steps. Two or three; four starts to wrap badly. */
steps?: PillStep[];
/** Shown INSTEAD of steps when provided — the same band as an offer block. */
body?: string;
cta?: { label: string; href: string } | null;
imageSrc?: string;
imageAlt?: string;
imageSide?: "left" | "right";
/** Landscape cut-outs need a wider cap than portrait ones or they bind on
* width and waste the height they could have filled. */
imageMaxWidth?: string;
floodFrom?: string;
floodTo?: string;
accentDeep?: string;
accentSoft?: string;
}
export default function PillBand({
kicker = "Virtual check-ins",
headline = "For Aligners",
steps = [
{ n: "Step 1", title: "Share Your Goals", body: "Tell us what you want from treatment from the comfort of home, and we design a plan around it." },
{ n: "Step 2", title: "Create A Plan", body: "We keep things on track with virtual check-ins, so you only come in when it counts." },
],
body,
cta = { label: "Ask about it", href: "#contact" },
imageSrc = "/heroes/flood-cutout/family.webp",
imageAlt = "A family standing together",
imageSide = "right",
imageMaxWidth = "min(52%, 660px)",
floodFrom = "#ec96a1",
floodTo = "#e2808d",
accentDeep = "#b04c5b",
accentSoft = "#fbd9dd",
}: PillBandProps) {
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 q1 = false;
const move = (e: PointerEvent) => {
if (q1) return;
q1 = true;
requestAnimationFrame(() => {
q1 = 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");
}
});
};
const cut = root.querySelector<HTMLElement>("[data-zoom]");
let q2 = false;
const tick = () => {
if (!cut) return;
const r = root.getBoundingClientRect();
if (r.bottom < -200 || r.top > window.innerHeight + 200) return;
let t = 1 - (r.top + r.height / 2) / (window.innerHeight + r.height / 2);
t = t < 0 ? 0 : t > 1 ? 1 : t;
cut.style.setProperty("--dz", (1 + t * 0.14).toFixed(4));
cut.style.setProperty("--dy", (t * 38).toFixed(1) + "px");
};
const onScroll = () => {
if (q2) return;
q2 = true;
requestAnimationFrame(() => { q2 = false; tick(); });
};
tick();
window.addEventListener("pointermove", move, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, []);
const right = imageSide === "right";
const css = `
/* THE SLASH IS THE SHAPE. 46% horizontal against 6% vertical gives a wide
shallow lozenge; a single value would give a circle. */
.pb { position: relative; overflow: hidden; margin: 20px 0;
padding: 76px 20px; font-family: 'Archivo', system-ui, sans-serif;
background: linear-gradient(140deg, ${floodFrom}, ${floodTo});
border-radius: 40px; }
@media (min-width: 768px) {
.pb { padding: 120px 28px; border-radius: 46% 46% 46% 46% / 6% 6% 6% 6%; }
}
.pb, .pb * { box-sizing: border-box; }
.pb-blob { position: absolute; pointer-events: none;
transform: translate3d(var(--tx,0px), var(--ty,0px), 0); will-change: transform; }
.pb-cut { position: relative; margin-top: 32px; display: flex;
align-items: flex-end; justify-content: center; height: 280px; width: 100%;
transform-origin: 50% 100%;
transform: translate3d(0, var(--dy,0px), 0) scale(var(--dz,1));
will-change: transform; }
@media (min-width: 768px) {
.pb-cut { position: absolute; bottom: 0; margin-top: 0; height: 94%;
width: ${imageMaxWidth};
${right ? "right: calc(-1% - 30px); justify-content: flex-end;" : "left: calc(-1% - 30px); justify-content: flex-start;"} }
}
.pb-cut img { height: 100%; width: 100%; object-fit: contain;
object-position: bottom; display: block; }
.pb-in { position: relative; max-width: 1180px; margin: 0 auto;
display: grid; gap: 40px; align-items: center; }
@media (min-width: 768px) { .pb-in { grid-template-columns: 1fr 1fr; gap: 48px; } }
.pb-spacer { display: none; }
@media (min-width: 768px) { .pb-spacer { display: block; height: 540px; } }
.pb-kick { margin: 0; font-size: clamp(22px,2.8vw,30px); font-weight: 500;
text-transform: uppercase; letter-spacing: -.02em;
color: color-mix(in srgb, ${accentDeep} 55%, #2b2530); }
.pb-h { margin: 4px 0 46px; font-size: clamp(38px,5.4vw,60px); color: #fff;
text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
.pb-steps { display: grid; gap: 34px; }
@media (min-width: 560px) { .pb-steps { grid-template-columns: 1fr 1fr; } }
.pb-n { margin: 0 0 8px; font-size: 12px; letter-spacing: .2em;
text-transform: uppercase; color: rgba(255,255,255,.75); }
.pb-t { margin: 0 0 10px; font-size: 28px; color: #fff;
text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
.pb-p { margin: 0; font-size: 14px; line-height: 1.65; color: rgba(255,255,255,.85); }
.pb-body { margin: 0 0 36px; max-width: 480px; font-size: 16px; line-height: 1.7;
color: rgba(255,255,255,.9); text-wrap: pretty; }
.pb-cta { display: inline-flex; align-items: center; gap: 12px; margin-top: 44px;
border: 1px solid rgba(255,255,255,.7); color: #fff; text-decoration: none;
padding: 14px 28px; border-radius: 4px; font-size: 14px; letter-spacing: .04em;
transition: background .3s ease; }
.pb-cta:hover { background: rgba(255,255,255,.15); color: #fff; }
@media (prefers-reduced-motion: reduce) {
.pb-blob, .pb-cut { transform: none !important; }
}
`;
const copy = (
<div>
{kicker && <p className="pb-kick">{kicker}</p>}
{headline && <h2 className="pb-h">{headline}</h2>}
{body ? (
<p className="pb-body">{body}</p>
) : (
<div className="pb-steps">
{steps.map((s) => (
<div key={s.n + s.title}>
<p className="pb-n">{s.n}</p>
<p className="pb-t">{s.title}</p>
<p className="pb-p">{s.body}</p>
</div>
))}
</div>
)}
{cta && <a className="pb-cta" href={cta.href}>{cta.label} <span aria-hidden="true">→</span></a>}
</div>
);
return (
<section className="pb" 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="pb-blob" data-depth="2.2" aria-hidden="true"
style={{ right: "8%", top: "10%", width: 300, height: 300, background: "rgba(255,255,255,.12)", borderRadius: "58% 42% 46% 54% / 52% 48% 52% 48%" }} />
<div className="pb-blob" data-depth="1.1" aria-hidden="true"
style={{ right: "34%", bottom: "12%", width: 210, height: 210, background: accentSoft, opacity: .5, borderRadius: "46% 54% 62% 38% / 56% 42% 58% 44%" }} />
<div className="pb-blob" data-depth="3" aria-hidden="true"
style={{ left: "8%", top: "16%", width: 150, height: 150, background: "rgba(255,255,255,.10)", borderRadius: "62% 38% 44% 56% / 52% 58% 42% 48%" }} />
<div className="pb-cut" data-zoom>
<img src={imageSrc} alt={imageAlt} loading="lazy" decoding="async" />
</div>
<div className="pb-in">
{right ? <>{copy}<div className="pb-spacer" aria-hidden="true" /></>
: <><div className="pb-spacer" aria-hidden="true" />{copy}</>}
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add pill-bandWhere to use it
Use for a process ("how it works") or a single offer. Two of these at different points in a page work well when one carries steps and the other carries an offer — same shape, different job.
In Astro:
---
import PillBand from '../components/innovations/content/pill-band';
---
<PillBand client:visible />
THE SHAPE IS ONE BORDER-RADIUS AND THE SLASH IS THE TRICK:
border-radius: 46% 46% 46% 46% / 6% 6% 6% 6%
Horizontal radii of 46% against vertical radii of 6% give a very wide, very shallow curve on all four corners — a lozenge. Write it as a single value and you get a circle. On mobile it falls back to a plain 40px radius, because a lozenge at phone width is just a distorted rectangle.
Because the band floats rather than touching the viewport edges, it needs neither the mask nor the waves that arc-quote uses. That is the whole reason to reach for this one instead.
Pass "steps" for a process, or "body" for an offer — body wins when both are set. Two or three steps; four wrap badly in the two-up grid.
THE CUT-OUT scales up and slides DOWN from a bottom origin, so the figure stays planted on the band's lower edge. imageMaxWidth exists because landscape cut-outs need a wider cap than portrait ones: with a portrait-tuned cap a landscape photo binds on width and wastes the height it could have filled. Portrait ~min(52%,660px), landscape ~min(68%,980px).