heroes /
Arc Slider Hero (Practice / Local Business)
Flooded band ending in a wide curve, with a rotating cut-out subject standing on that curve. Drifting blobs, a spark field with cursor parallax and a glow, a floating proof badge, and a scroll zoom that grows the figure from a pinned bottom. The curve is two 300vw circles — one fills, one masks — so it resizes perfectly and needs no path data.
Preview
Source
tsx
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Arc Slider hero — a flooded band that ends in a wide curve, with a rotating
* cut-out subject standing on that curve.
*
* THE CURVE IS A 300vw CIRCLE, not an SVG path, and that is the whole trick. A
* circle that wide is nearly flat across a desktop viewport, so its top edge
* reads as a gentle sweep instead of a dome. A second copy of the same circle
* carrying `box-shadow: 0 0 0 100vmax <next colour>` paints everything OUTSIDE
* itself — that shadow IS the mask, and it is what makes the flood appear to end
* in a curve. Two divs, no path data, and it resizes perfectly because the only
* unit involved is vw.
*
* Ported from a High Country Dental build. Every colour and every string is a
* prop; the demo content is placeholder.
*/
export interface ArcSlide {
kicker: string;
title: string;
body: string;
cta: { label: string; href: string };
imageSrc: string;
imageAlt: string;
/** Optional per-slide title size. A long word needs its own clamp. */
titleSize?: string;
}
export interface ArcSliderHeroProps {
brand?: string;
navLinks?: { label: string; href: string }[];
navCta?: { label: string; href: string };
slides?: ArcSlide[];
/** Flood gradient, light end → deep end. */
floodFrom?: string;
floodTo?: string;
/** MUST match the section that follows: the mask is painted IN this colour,
* so a mismatch shows as a band across the seam. */
nextSectionColor?: string;
/** Small proof chip floating on the curve. Omit for no badge — never invent
* a rating to fill it. */
badge?: { score: string; count: string; source: string } | null;
/** Seconds between slides. 0 disables autoplay entirely. */
autoplaySeconds?: number;
}
const DEMO: ArcSlide[] = [
{
kicker: "Your dream visit.",
title: "Smile On.",
body: "Comprehensive care in one practice. Advanced technology, a comfort-first approach and a team that puts people before procedures.",
cta: { label: "Book now", href: "#book" },
imageSrc: "/heroes/pastel-flood/couple.webp",
imageAlt: "A laughing couple",
},
{
kicker: "New patients",
title: "First Visit",
body: "Your first appointment covers a full exam, a set of X-rays and a clean — the visit that tells you where you actually stand.",
cta: { label: "See the offer", href: "#offer" },
imageSrc: "/heroes/flood-cutout/family.webp",
imageAlt: "A family of four",
},
{
kicker: "Same-day relief",
title: "Emergency",
body: "Pain, a break, or something that cannot wait until next week. Call and we will find you a slot today wherever we can.",
cta: { label: "Get seen today", href: "#contact" },
imageSrc: "/heroes/cutout-blur-band/subject.webp",
imageAlt: "A smiling person",
titleSize: "clamp(45px,7.2vw,83px)",
},
];
export default function ArcSliderHero({
brand = "Northgate",
navLinks = [
{ label: "About", href: "#about" },
{ label: "Services", href: "#services" },
{ label: "Contact", href: "#contact" },
],
navCta = { label: "(555) 010-4477", href: "tel:5550104477" },
slides = DEMO,
floodFrom = "#ec96a1",
floodTo = "#d26575",
nextSectionColor = "#ffffff",
badge = { score: "4.8", count: "1,502", source: "Google reviews" },
autoplaySeconds = 6.5,
}: ArcSliderHeroProps) {
const [i, setI] = useState(0);
const [stopped, setStopped] = useState(false);
const rootRef = useRef<HTMLElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
// Autoplay stops permanently on first interaction. A carousel that keeps
// moving after someone has taken control is the thing everyone hates.
useEffect(() => {
if (stopped || !autoplaySeconds) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const t = setInterval(() => setI((n) => (n + 1) % slides.length), autoplaySeconds * 1000);
return () => clearInterval(t);
}, [stopped, autoplaySeconds, slides.length]);
// Cursor glow and spark parallax. The listener sits on the SECTION, not on the
// spark layer: the layer is behind the cut-out, so it would stop firing the
// moment the cursor crossed the photograph.
useEffect(() => {
const host = rootRef.current;
const field = fieldRef.current;
if (!host || !field) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const move = (e: PointerEvent) => {
const r = host.getBoundingClientRect();
field.style.setProperty("--px", e.clientX - r.left + "px");
field.style.setProperty("--py", e.clientY - r.top + "px");
field.style.setProperty("--pon", "1");
field.style.setProperty("--mx", ((e.clientX / window.innerWidth - 0.5) * 16).toFixed(1) + "px");
field.style.setProperty("--my", ((e.clientY / window.innerHeight - 0.5) * 16).toFixed(1) + "px");
};
const leave = () => field.style.setProperty("--pon", "0");
host.addEventListener("pointermove", move, { passive: true });
host.addEventListener("pointerleave", leave);
return () => {
host.removeEventListener("pointermove", move);
host.removeEventListener("pointerleave", leave);
};
}, []);
const go = (n: number) => {
setStopped(true);
setI((n + slides.length) % slides.length);
};
const css = `
.as { position: relative; overflow: hidden;
padding: 56px 20px 380px;
font-family: 'Archivo', system-ui, sans-serif; }
@media (min-width: 768px) { .as { padding: 96px 28px 200px; } }
.as, .as * { box-sizing: border-box; }
.as-d { text-transform: uppercase; letter-spacing: -.02em; font-weight: 700; }
/* The arc pair. Same geometry twice: one fills, one masks. */
.as-arc { position: absolute; left: 50%; transform: translateX(-50%);
width: 300vw; height: 300vw; border-radius: 50%; bottom: 0; pointer-events: none; }
.as-fill { background: linear-gradient(160deg, ${floodFrom} 0%, ${floodTo} 100%); }
.as-mask { box-shadow: 0 0 0 100vmax ${nextSectionColor}; }
.as-blobs { position: absolute; inset: 0; pointer-events: none; }
.as-blob { position: absolute; border-radius: 58% 42% 46% 54% / 52% 48% 52% 48%; }
@keyframes as-bobA { 0%,100% { transform: translate3d(0,0,0) rotate(0deg); } 50% { transform: translate3d(14px,-22px,0) rotate(4deg); } }
@keyframes as-bobB { 0%,100% { transform: translate3d(0,0,0) scale(1); } 50% { transform: translate3d(-18px,16px,0) scale(1.06); } }
@keyframes as-spark { 0%,100% { transform: translate3d(0,0,0); opacity: .35; } 50% { transform: translate3d(10px,-38px,0); opacity: .9; } }
@keyframes as-toast { 0%,100% { transform: translate3d(0,0,0) rotate(-1deg); } 50% { transform: translate3d(6px,-12px,0) rotate(1deg); } }
@keyframes as-in { from { opacity: 0; transform: scale(1.45); } to { opacity: 1; transform: scale(1); } }
@keyframes as-drift { from { transform: scale(1) translateY(0); } to { transform: scale(1.28) translateY(130px); } }
/* SCROLL-DRIVEN, AND WRITTEN AS LONGHANDS ON PURPOSE. Fold these into the
"animation" shorthand — or into a Tailwind animate-[...] arbitrary value —
and a CSS minifier will emit "animation: linear both as-drift scroll(root)".
A timeline is not a legal component of that shorthand, so the declaration
is invalid and the browser drops it: the effect dies silently in the built
bundle while working perfectly in dev. */
@supports (animation-timeline: scroll(root)) {
@media (prefers-reduced-motion: no-preference) {
.as-cut {
animation-name: as-drift;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(root);
animation-range: 0 800px;
}
}
}
.as-field { position: absolute; inset: 0; pointer-events: none; }
.as-glow { position: absolute; left: var(--px, 50%); top: var(--py, 50%);
width: 340px; height: 340px; margin: -170px 0 0 -170px; border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,.22), rgba(255,255,255,0) 68%);
transition: left .5s ease-out, top .5s ease-out, opacity .4s ease;
opacity: var(--pon, 0); }
.as-spark { position: absolute;
transform: translate3d(calc(var(--mx,0px) * var(--k)), calc(var(--my,0px) * var(--k)), 0);
transition: transform .8s cubic-bezier(.16,1,.3,1); }
.as-spark > span { display: block; border-radius: 50%; }
/* THE CUT-OUT. Sized by WIDTH and pinned to the bottom, with the transform
origin at 50% 100% — without that origin the scroll zoom grows from the
centre and the figure pushes straight through the curved edge. It carries
NO z-index, so the mask (a later sibling) paints over it and the curve
actually clips the photograph. */
.as-cut { position: absolute; bottom: 0; right: 50%; transform-origin: 50% 100%;
display: flex; align-items: flex-end; justify-content: center;
height: 340px; width: min(88%, 420px); transform: translateX(50%); }
@media (min-width: 768px) {
.as-cut { right: calc(-4% - 100px); transform: none;
height: 96%; width: min(78%, 1120px); justify-content: flex-end; }
}
.as-stack { position: relative; height: 100%; max-height: 100%; width: 100%;
aspect-ratio: 1000 / 1155; transform-origin: 50% 100%;
animation: as-in 1.1s cubic-bezier(.16,1,.3,1) both; }
.as-stack img { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: contain; object-position: bottom; display: block;
opacity: 0; transform: translateX(40px);
transition: opacity .5s ease, transform .6s cubic-bezier(.16,1,.3,1); }
.as-stack img[data-on="true"] { opacity: 1; transform: none; }
.as-badge { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 5; }
@media (min-width: 768px) { .as-badge { bottom: 78px; } }
.as-badge-in { animation: as-toast 9s ease-in-out infinite; background: #fff;
border-radius: 54% 46% 48% 52% / 60% 58% 42% 40%;
padding: 16px 26px; display: flex; align-items: center; gap: 14px;
box-shadow: 0 22px 60px rgba(0,0,0,.16); }
@media (min-width: 768px) { .as-badge-in { padding: 22px 40px; } }
.as-grid { position: relative; z-index: 3; max-width: 1180px; margin: 0 auto;
display: grid; gap: 40px; align-items: center; }
@media (min-width: 768px) { .as-grid { grid-template-columns: minmax(280px,440px) 1fr; } }
.as-copy { position: relative; min-height: 380px; }
@media (min-width: 768px) { .as-copy { min-height: 430px; } }
.as-slide { position: absolute; inset: 0 auto auto 0; width: 100%;
opacity: 0; transform: translateY(14px); pointer-events: none;
transition: opacity .45s ease, transform .55s cubic-bezier(.16,1,.3,1); }
.as-slide[data-on="true"] { position: relative; opacity: 1; transform: none; pointer-events: auto; }
.as-btn { display: inline-flex; align-items: center; gap: 14px; background: #fff;
color: #33323e; text-decoration: none; border-radius: 6px; padding: 16px 30px;
font-size: 15px; font-weight: 600; letter-spacing: .03em;
transition: transform .3s ease, box-shadow .3s ease;
box-shadow: 0 18px 40px rgba(140,40,60,.28); }
.as-btn:hover { transform: translateY(-3px); }
.as-ctrl { position: relative; z-index: 4; display: flex; align-items: center;
gap: 22px; margin-top: 46px; }
.as-ctrl button { width: 44px; height: 44px; border-radius: 50%; cursor: pointer;
border: 1px solid rgba(255,255,255,.6); background: transparent; color: #fff;
font-size: 16px; transition: background .3s ease, color .3s ease; }
.as-ctrl button:hover { background: #fff; color: ${floodTo}; }
.as-dot { width: 8px; height: 8px; border-radius: 999px; border: 0; padding: 0;
cursor: pointer; background: rgba(255,255,255,.45);
transition: width .4s ease, background .4s ease; }
.as-dot[data-on="true"] { width: 28px; background: #fff; }
.as-nav { position: relative; z-index: 4; display: flex; align-items: center;
justify-content: space-between; gap: 24px; max-width: 1180px; margin: 0 auto 40px; }
.as-nav a { color: rgba(255,255,255,.9); text-decoration: none; font-size: 15px; }
.as-nav a:hover { color: #fff; }
.as-nav-links { display: none; gap: 26px; align-items: center; }
@media (min-width: 900px) { .as-nav-links { display: flex; } }
.as-nav-cta { background: #33323e; color: #fff !important; border-radius: 999px;
padding: 11px 22px; font-size: 14px; letter-spacing: .04em; }
@media (prefers-reduced-motion: reduce) {
.as-blob, .as-badge-in, .as-stack, .as-spark > span { animation: none !important; }
}
`;
const SPARKS = [
{ l: "12%", t: "22%", k: 1.6, s: 8, ring: true, d: "0s" },
{ l: "31%", t: "14%", k: 2.2, s: 12, ring: false, d: ".8s" },
{ l: "46%", t: "34%", k: 1.8, s: 9, ring: false, d: ".3s" },
{ l: "22%", t: "62%", k: 0.7, s: 5, ring: false, d: "1.5s" },
{ l: "62%", t: "18%", k: 2.6, s: 10, ring: true, d: "1.9s" },
{ l: "74%", t: "48%", k: 1.3, s: 6, ring: false, d: ".6s" },
];
return (
<section className="as" ref={rootRef}>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" />
<style>{css}</style>
<div className="as-arc as-fill" />
<div className="as-blobs" aria-hidden="true">
<div className="as-blob" style={{ width: 420, height: 420, right: -90, top: -120, background: "rgba(255,255,255,.14)", animation: "as-bobA 16s ease-in-out infinite" }} />
<div className="as-blob" style={{ width: 220, height: 220, left: "6%", bottom: "4%", background: "rgba(255,255,255,.12)", animation: "as-bobB 20s ease-in-out infinite" }} />
<div className="as-blob" style={{ width: 300, height: 300, left: "34%", top: "12%", background: "rgba(255,255,255,.08)", animation: "as-bobB 26s ease-in-out infinite" }} />
</div>
<div className="as-field" ref={fieldRef} aria-hidden="true">
<div className="as-glow" />
{SPARKS.map((s, n) => (
<div key={n} className="as-spark" style={{ left: s.l, top: s.t, ["--k" as string]: s.k }}>
<span style={{
width: s.s, height: s.s,
animation: `as-spark ${13 + n * 2}s ease-in-out ${s.d} infinite`,
...(s.ring ? { border: "1.5px solid rgba(255,255,255,.7)" } : { background: "rgba(255,255,255,.75)" }),
}} />
</div>
))}
</div>
<div className="as-cut">
<div className="as-stack">
{slides.map((s, n) => (
<img key={s.imageSrc + n} src={s.imageSrc} alt={s.imageAlt}
data-on={n === i} loading={n === 0 ? "eager" : "lazy"} decoding="async" />
))}
</div>
</div>
{/* The mask. Same circle again, painting everything outside it. */}
<div className="as-arc as-mask" />
{badge && (
<div className="as-badge">
<div className="as-badge-in">
<span style={{ color: "#f0a02a", fontSize: 15, letterSpacing: 1 }} aria-hidden="true">★★★★★</span>
<span className="as-d" style={{ fontSize: 30, fontWeight: 800, color: "#33323e" }}>{badge.score}</span>
<span style={{ fontSize: 12.5, lineHeight: 1.35, color: "#5b5866" }}>
from <b>{badge.count}</b><br />{badge.source}
</span>
</div>
</div>
)}
<header className="as-nav">
<a href="#top" className="as-d" style={{ fontSize: 20, color: "#fff" }}>{brand}</a>
<nav className="as-nav-links">
{navLinks.map((l) => <a key={l.href + l.label} href={l.href}>{l.label}</a>)}
{navCta && <a className="as-nav-cta" href={navCta.href}>{navCta.label}</a>}
</nav>
</header>
<div className="as-grid">
<div className="as-copy">
{slides.map((s, n) => (
<div key={s.title + n} className="as-slide" data-on={n === i}>
<p className="as-d" style={{ margin: 0, fontSize: "clamp(28px,4vw,44px)", fontWeight: 500, letterSpacing: ".01em", color: "#fff" }}>{s.kicker}</p>
<h1 className="as-d" style={{ margin: "2px 0 26px", lineHeight: .94, color: "#fff", fontSize: s.titleSize ?? "clamp(56px,9vw,104px)" }}>{s.title}</h1>
<p style={{ margin: "0 0 34px", maxWidth: 340, fontSize: 17, lineHeight: 1.65, color: "rgba(255,255,255,.9)" }}>{s.body}</p>
<a className="as-btn" href={s.cta.href}>{s.cta.label} <span style={{ color: floodTo }}>→</span></a>
</div>
))}
<div className="as-ctrl">
<button type="button" aria-label="Previous slide" onClick={() => go(i - 1)}>←</button>
<button type="button" aria-label="Next slide" onClick={() => go(i + 1)}>→</button>
<div style={{ display: "flex", gap: 10, marginLeft: 8 }}>
{slides.map((_, n) => (
<button key={n} type="button" className="as-dot" data-on={n === i}
aria-label={`Go to slide ${n + 1}`} onClick={() => go(n)} />
))}
</div>
</div>
</div>
<div />
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add arc-sliderWhere to use it
Use for a local practice, clinic, salon, studio — anywhere a person should be standing in the hero and the brand has one strong colour. It is warm and rounded by construction; on a technical or enterprise product it reads as the wrong industry.
In Astro:
---
import ArcSliderHero from '../components/innovations/heroes/arc-slider';
---
<ArcSliderHero client:load />
THE CURVE IS TWO 300vw CIRCLES AND NO SVG. One is filled with the flood gradient; the second is identical geometry carrying box-shadow: 0 0 0 100vmax <nextSectionColor>, which paints everything OUTSIDE the circle — that shadow IS the mask, and it is what makes the flood appear to end in a curve. A circle that wide is nearly flat across a desktop viewport, which is why the arc reads as a gentle sweep rather than a dome. Because the only unit is vw it stays correct at every width with no media queries.
nextSectionColor MUST match whatever section follows. The mask is painted in that colour, so a mismatch ships as a coloured band across the seam that looks like a rendering bug.
THREE THINGS ABOUT THE CUT-OUT, all of which were bugs before they were rules:
· It is sized by WIDTH — min(78%,1120px) at 96% tall, bleeding off the edge. Size it by height instead and the figure comes out small and sits in the corner instead of filling the side.
· transform-origin is 50% 100%. The scroll zoom scales to 1.28, and from a centre origin the figure grows DOWNWARD and pushes straight through the curved edge.
· It carries NO z-index. The mask is a later sibling at z-auto, so DOM order paints it over the photograph and the curve actually clips it. Give the photo a z-index and the overlap comes back.
The photo must be a cut-out with real alpha, and it wants a subject whose energy survives being cropped — a laugh, a gesture, two people.
The scroll animation is written as LONGHANDS (animation-name, animation-timing-function, animation-fill-mode, animation-timeline) and must stay that way. Folded into the "animation" shorthand — or into a Tailwind animate-[...] arbitrary value — a CSS minifier emits 'animation: linear both as-drift scroll(root)', which is invalid because a timeline is not a component of that shorthand. The browser drops the whole declaration and the effect dies silently in the built bundle while working perfectly in dev.
badge takes a real score and count or nothing at all. Pass null rather than inventing a rating to fill the space — it is the one element a reader treats as independent proof.
autoplaySeconds={0} disables rotation. Autoplay stops permanently on the first click either way: a carousel that keeps moving after someone has taken control is the thing everyone hates about carousels.