Innovations
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-slider

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