content /
Arc Quote (Curved Flood + Testimonial)
A flooded band that curves in at the top via a 300vw circle and ends in a pair of offset SVG waves at the bottom, with a cut-out figure standing on the lower edge and a testimonial alongside. The figure scales up and slides down with scroll from a pinned bottom.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
/**
* Arc Quote — a flooded band that CURVES IN at the top and ends in stacked
* waves at the bottom, with a cut-out figure standing on the lower edge and a
* testimonial alongside.
*
* TWO DIFFERENT EDGE TREATMENTS ON ONE BAND, and that is deliberate: the top is
* a 300vw circle (a single wide sweep) and the bottom is a pair of offset SVG
* waves. Using the same device twice would make the band read as a pill; mixing
* them makes it read as a landscape.
*
* THE BACK WAVE AT .4 OPACITY IS THE POINT. One path is a flat cut; two, offset
* vertically with the rear one faded, gives the edge depth.
*/
export interface ArcQuoteProps {
kicker?: string;
headline?: string;
/** A real quote or nothing. Never a written-for-the-comp one. */
quote?: string;
attribution?: string;
attributionSub?: string;
/** Cut-out with real alpha, standing on the band's bottom edge. */
imageSrc?: string;
imageAlt?: string;
imageSide?: "left" | "right";
floodFrom?: string;
floodTo?: string;
/** The colour of whatever sits above AND below — both edges are cut in it. */
surroundColor?: string;
accentDeep?: string;
}
export default function ArcQuote({
kicker = "We can help you have a",
headline = "Natural Smile",
quote =
"Excellent service, very modernised. They take a great deal of care over the patient experience, and explained the costs and my insurance before anything was booked.",
attribution = "Aaron N.",
attributionSub = "Google review",
imageSrc = "/heroes/product-cutout/model.webp",
imageAlt = "A person standing",
imageSide = "right",
floodFrom = "#ec96a1",
floodTo = "#e2808d",
surroundColor = "#ffffff",
accentDeep = "#b04c5b",
}: ArcQuoteProps) {
const rootRef = useRef<HTMLElement | null>(null);
// Cursor parallax on the decorative blobs, and a scroll-driven zoom on the
// cut-out. The zoom is position-driven, so scrolling back up unwinds it
// exactly rather than replaying.
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;
// Scale UP and slide DOWN, from a bottom origin: the feet stay pinned to
// the band edge and the extra travel goes into the band's overflow clip.
// Sliding up instead lifts the figure off the edge it stands on.
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 = `
.aq { position: relative; overflow: hidden; padding: 120px 20px 150px;
font-family: 'Archivo', system-ui, sans-serif; }
@media (min-width: 768px) { .aq { padding: 190px 28px; } }
.aq, .aq * { box-sizing: border-box; }
/* TOP EDGE: one 300vw circle to fill, one to mask. */
.aq-arc { position: absolute; left: 50%; transform: translateX(-50%); top: 0;
width: 300vw; height: 300vw; border-radius: 50%; pointer-events: none; }
.aq-fill { background: linear-gradient(140deg, ${floodFrom}, ${floodTo}); }
.aq-mask { box-shadow: 0 0 0 100vmax ${surroundColor}; }
.aq-blob { position: absolute; pointer-events: none;
transform: translate3d(var(--tx,0px), var(--ty,0px), 0); will-change: transform; }
/* BOTTOM EDGE: two offset waves, the rear one faded. */
.aq-waves { position: absolute; left: 0; bottom: -1px; width: 100%; height: 120px;
display: block; pointer-events: none; }
.aq-cut { position: relative; margin-top: 36px; display: flex;
align-items: flex-end; justify-content: center; height: 300px; width: 100%;
transform-origin: 50% 100%;
transform: translate3d(0, var(--dy,0px), 0) scale(var(--dz,1));
will-change: transform; }
@media (min-width: 768px) {
.aq-cut { position: absolute; bottom: 0; margin-top: 0; height: 92%;
width: min(56%, 720px);
${right ? "right: calc(-2% - 40px); justify-content: flex-end;" : "left: calc(-2% - 40px); justify-content: flex-start;"} }
}
.aq-cut img { height: 100%; width: 100%; object-fit: contain;
object-position: bottom; display: block; }
.aq-in { position: relative; max-width: 1180px; margin: 0 auto; display: grid;
gap: 44px; align-items: center; }
@media (min-width: 768px) { .aq-in { grid-template-columns: 1fr 1fr; gap: 56px; } }
.aq-spacer { display: none; }
@media (min-width: 768px) { .aq-spacer { display: block; height: 520px; } }
.aq-kick { margin: 0; font-size: clamp(24px,3vw,34px); font-weight: 500;
text-transform: uppercase; letter-spacing: -.02em;
color: color-mix(in srgb, ${accentDeep} 55%, #2b2530); }
.aq-h { margin: 4px 0 34px; font-size: clamp(40px,5.6vw,64px); color: #fff;
text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
.aq-q { margin: 0; max-width: 520px; font-size: 22px; line-height: 1.6;
color: #fff; text-wrap: pretty; }
.aq-who { margin: 34px 0 2px; font-size: 24px; color: #fff;
text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
.aq-sub { margin: 0; font-size: 13px; letter-spacing: .14em;
text-transform: uppercase; color: rgba(255,255,255,.75); }
@media (prefers-reduced-motion: reduce) {
.aq-blob, .aq-cut { transform: none !important; }
}
`;
const cut = (
<div className="aq-cut" data-zoom>
<img src={imageSrc} alt={imageAlt} loading="lazy" decoding="async" />
</div>
);
return (
<section className="aq" 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="aq-arc aq-fill" />
<div className="aq-arc aq-mask" />
<div className="aq-blob" data-depth="2.4" aria-hidden="true"
style={{ left: "6%", top: "16%", width: 300, height: 300, background: "rgba(255,255,255,.16)", borderRadius: "58% 42% 46% 54% / 52% 48% 52% 48%" }} />
<div className="aq-blob" data-depth="1.4" aria-hidden="true"
style={{ right: "14%", top: "8%", width: 220, height: 220, background: "color-mix(in srgb, #fff 30%, transparent)", borderRadius: "46% 54% 62% 38% / 56% 42% 58% 44%" }} />
<div className="aq-blob" data-depth="3" aria-hidden="true"
style={{ left: "38%", bottom: "12%", width: 170, height: 170, background: "rgba(255,255,255,.12)", borderRadius: "62% 38% 44% 56% / 52% 58% 42% 48%" }} />
{cut}
<svg className="aq-waves" viewBox="0 0 1440 130" preserveAspectRatio="none" aria-hidden="true">
<path d="M0,38 C240,108 480,-12 720,38 C960,88 1200,8 1440,58 L1440,130 L0,130 Z" fill={surroundColor} opacity=".4" />
<path d="M0,72 C240,132 480,22 720,72 C960,122 1200,32 1440,82 L1440,130 L0,130 Z" fill={surroundColor} />
</svg>
<div className="aq-in">
{right ? (
<>
<div>
{kicker && <p className="aq-kick">{kicker}</p>}
{headline && <h2 className="aq-h">{headline}</h2>}
{quote && <blockquote className="aq-q">“ {quote} ”</blockquote>}
{attribution && <p className="aq-who">{attribution}</p>}
{attributionSub && <p className="aq-sub">{attributionSub}</p>}
</div>
<div className="aq-spacer" aria-hidden="true" />
</>
) : (
<>
<div className="aq-spacer" aria-hidden="true" />
<div>
{kicker && <p className="aq-kick">{kicker}</p>}
{headline && <h2 className="aq-h">{headline}</h2>}
{quote && <blockquote className="aq-q">“ {quote} ”</blockquote>}
{attribution && <p className="aq-who">{attribution}</p>}
{attributionSub && <p className="aq-sub">{attributionSub}</p>}
</div>
</>
)}
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add arc-quoteWhere to use it
Use for the one testimonial worth giving a whole band to. It is the loudest section in this family, so it should carry the strongest piece of proof available — and only a real one.
In Astro:
---
import ArcQuote from '../components/innovations/content/arc-quote';
---
<ArcQuote client:visible />
TWO DIFFERENT EDGE TREATMENTS ON ONE BAND, deliberately. The top is a single 300vw circle (one wide sweep) and the bottom is a pair of offset waves. Using the same device at both ends makes the band read as a pill; mixing them makes it read as a landscape.
THE BACK WAVE AT .4 OPACITY IS THE POINT of the bottom edge. One path is a flat cut. Two, offset vertically with the rear one faded, gives the edge depth. Do not simplify it to a single path.
surroundColor is used for BOTH the top mask and both wave fills, so it must match whatever sits above and below. Get it wrong and you ship coloured bands at two seams instead of one.
THE CUT-OUT SCALES UP AND SLIDES DOWN, from transform-origin 50% 100%. That combination keeps the feet pinned to the band edge and pushes the extra travel into the band's overflow clip. Sliding UP instead — which is the intuitive choice — lifts the figure off the edge it is standing on and opens a visible gap underneath. The motion is position-driven, so scrolling back up unwinds it exactly rather than replaying.
quote must be real. A band this size with an invented testimonial in it is worse than the same band with no quote at all — pass quote="" and it collapses cleanly. Attribute it to what it actually is ("Google review", not a job title you made up).