Ready to move different?

Join the club for fresh drops, race entries, and gear that keeps up — every single week.

Halo Hero

A hero section ringed by photo cards that orbit your headline — one wide ring or two side circles — each card swaying as it travels.

A free, copy-paste background animations component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.

Configuration

Tweak these live from theConfigurecontrol in the preview.

layoutsplit · ring — default "split".
shaperounded · square · circle — default "rounded".
hoverEffectlift · zoom · none — default "lift".
duration15–180s — default 60s.
directionclockwise · counterclockwise — default "clockwise".
ringWidth0.5–1.3 — default 0.9.
ringHeight0.5–1.3 — default 0.86.
cardSize90–240px — default 150px.
tilt0–60° — default 28°.
dim0–0.7 — default 0.15.
followScrollon / off — default off.
scrollBoost0–8× — default 3×.
pauseOnHoveron / off — default off.

Interaction Type

  • Photo cards orbit the headline on an endless ellipse
  • Optionally eases to a stop while the pointer is over it
  • Optionally follows the page's scroll: reverses on scroll-up and speeds up while scrolling
  • Parks itself off-screen and holds still for reduced motion

Dependency

No dependencies — plain React. Just drop the file in.

Point the CLI here once

A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:

npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"

After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/halo-hero.json — or copy the file from Source code below.

[ shadcn CLI 3.0 registry ]…or add the component in one command

Install via CLI

pnpm dlx shadcn add @harshdev-ui/halo-hero

How to use

Give Halo Hero a list of photos and put your hero copy inside it — a heading, a line of text, a couple of buttons. The photos become tilted cards spaced evenly around two circles hugging the side edges — or, with layout="ring", one wide ellipse around the content — and they turn slowly and endlessly behind your content, which stays centred and still.

Size the hero with a class (h-svh for a full first screen), then tune the ring: how long a turn takes and which way it goes, how wide and tall the ellipse is relative to the hero, how big the cards are and how far they lean. There are no dependencies — the motion is one requestAnimationFrame loop writing transforms, so nothing re-renders while it turns.

Basic usage

import { HaloHero } from "@/components/harsh-ui/halo-hero";

const photos = [
  "/hero/run-1.jpg",
  "/hero/run-2.jpg",
  "/hero/run-3.jpg",
  // …as many as you like; eight to fourteen fill the ring well
];

export function Hero() {
  return (
    <HaloHero images={photos} className="h-svh w-full">
      <h1 className="text-5xl font-medium tracking-tight text-white">
        Ready to move different?
      </h1>
      <p className="mt-4 max-w-sm text-sm text-white/60">
        Fresh drops, race entries and gear that keeps up.
      </p>
      <a href="/join" className="mt-8 rounded-lg bg-white px-5 py-2.5 text-sm font-medium text-black">
        Join the club
      </a>
    </HaloHero>
  );
}

Tuning the ring

A faster, counter-clockwise turn on a taller ring, with bigger cards that barely lean.

<HaloHero
  images={photos}
  duration={35}
  direction="counterclockwise"
  ringWidth={1}
  ringHeight={1.05}
  cardSize={190}
  tilt={12}
  className="h-svh w-full"
>
  {/* …your hero copy */}
</HaloHero>

One ring, round photos

Split circles are the default. layout="ring" puts every photo on one wide ellipse around the content instead, and shape="circle" with hoverEffect="zoom" turns them into round photos you can push into.

<HaloHero
  images={photos}
  layout="ring"
  shape="circle"
  hoverEffect="zoom"
  className="h-svh w-full"
>
  {/* …your hero copy */}
</HaloHero>

Following the scroll

followScroll ties the ring to the page: it turns forward while the reader scrolls down, reverses when they scroll back up, and speeds up while the page moves. scrollBoost sets how hard — 0 only flips the direction.

<HaloHero
  images={photos}
  followScroll
  scrollBoost={4}
  className="h-svh w-full"
>
  {/* …your hero copy */}
</HaloHero>

Pausing

pauseOnHover eases the ring to a stop under the pointer. paused holds it still from outside — while a modal is open, say — and it eases back up when released.

const [open, setOpen] = useState(false);

<HaloHero images={photos} pauseOnHover paused={open} className="h-svh w-full">
  {/* …your hero copy */}
</HaloHero>

Demo

import { HaloHeroDemo } from "@/components/demos/halo-hero-demo";

const Demo = () => (
  <div className="h-screen w-full">
    <HaloHeroDemo />
  </div>
);

Props

imagesstring[] — photo URLs, spaced evenly around the ring. Required.
childrenThe hero content, centred over the ring — a heading, copy, buttons.
layout"split" (default) puts a circle on each side edge, about half of each showing, with the content between them. On heroes narrower than 768px — phones, where the copy spans the width — the photos ride two gentle arcs instead, one above the copy and one below, drifting in opposite directions like the top and bottom of the ring. "ring" puts one ellipse around the content, and switches to the same arcs on narrow heroes.
shape"rounded" (default), "square", or "circle" — fully round photos.
hoverEffect"lift" (default) grows a photo and raises it above its neighbours, "zoom" pushes into the photo inside its frame, and "none" turns hover off so cards ignore the pointer entirely. Both effects also clear the dim on the hovered photo.
durationSeconds for one full turn of the ring. Default 60.
direction"clockwise" (default) or "counterclockwise" — with followScroll, the way it turns while scrolling down. In "split" the right circle mirrors the left.
followScrollFollow the page's scroll: the ring reverses when the reader scrolls up, turns forward again when they scroll down, and speeds up while the page is moving. Default false.
scrollBoostWith followScroll, how much a scroll speeds the ring up — the extra pace fades back out within a second or two. 0 only flips the direction. Default 3.
ringWidth / ringHeightIn "split", ringWidth is how far apart the two circles' centres sit and ringHeight their size. In "ring", they size the ellipse as a fraction of the hero's width and height — above 1 pushes cards past the edges. Defaults 0.9 and 0.86.
cardSizeLong edge of a card in px at a 1200px-wide hero; cards shrink on narrower heroes, down to 55%, and circles use 78% of it as their diameter. Default 150.
tiltHow far cards lean either way, in degrees. 0 keeps them level. Default 28.
dim0–1 fade of the photos toward the background colour — darker on a dark theme, lighter on a light one — so the headline stays the focus. Default 0.15.
pauseOnHoverEase the ring to a stop while the pointer is over the hero. Default false.
pausedHold the ring still from outside; it eases down and back up again. Default false.
background / classNameThe hero background, which also tints the scrim behind the content and the dim. Defaults to var(--background, #0d0d0d), so it follows a shadcn light or dark theme. className styles the root — give it a height.

Notes

  • In "ring" every card sits on one ellipse centred on the hero; its position is the cosine and sine of the ring's angle plus its own even offset, so the whole ring is a single number turning.
  • In "split" the same angle drives two circles, one centred near each side edge. The right circle uses the mirrored angle (π − θ), so the two turn symmetrically, and it starts halfway through the photo list so the sides don't read as copies. Their height follows ringHeight while how far in they reach is capped at a fifth of the hero, so the inner-most cards stop about a quarter of the way in, clear of the copy. Below 768px wide the same cards ride two looping arcs instead, one above the copy and one below, the top row drifting one way and the bottom row the other. The loop picks that geometry from the hero's width every frame, so switching needs no re-render and nothing flashes.
  • Cards sway rather than spin: each card's lean drifts with a sine of where it is on the ring, so the ring feels alive but no photo is ever carried round upside down.
  • Each card also sits a little inside or outside the line, and every third one is portrait, so the ring reads as scattered photos rather than a drawn circle. The offsets are seeded per index, so they never reshuffle between renders.
  • One requestAnimationFrame loop writes translate3d and rotate transforms straight onto the cards — React never re-renders while the ring turns — and the loop parks itself whenever the ring is at rest.
  • An IntersectionObserver stops the loop while the hero is off-screen, and prefers-reduced-motion holds the ring still.
  • Pausing, from pauseOnHover or the paused prop, eases the speed down instead of freezing mid-frame. The angle lives in a ref, so changing a prop never snaps the ring back to its start.
  • The ring is aria-hidden, so screen readers go straight to your content. The ring layer ignores the pointer and only the cards take it — and only when hoverEffect isn't "none" — while the content column passes the pointer through the gaps between its children, so a card sliding behind the space between two buttons can still be hovered.
  • Hover effects live on an inner layer: the animation loop owns each card's outer transform, so lift's scale and zoom's push-in sit one element down and never fight the per-frame position. A hovered card also rises above its neighbours and sheds its dim.
  • Colours follow the theme. The background defaults to var(--background, #0d0d0d), and that same colour tints both the soft scrim that keeps the copy legible and the dim over the photos — so on a light theme the photos fade toward white instead of darkening.
  • followScroll listens to the window's scroll, including Lenis-style smooth scrolling, which moves the real scroll position. Each scroll sets the direction from its sign and adds to a boost that grows with how far the page moved, capped at four times scrollBoost, and the boost decays every frame. Speed is a signed value eased toward its target, so a reversal slows through a stop instead of snapping.
  • Cards render transparent and fade in only once the loop has sized and placed them. Before that a card has no size, so its photo would flash at its full natural size on the first paint.
  • In a grid-card preview the demo keeps the ring paused until the card is hovered, so a wall of previews isn't turning a dozen rings at once.

Source code

The complete halo-hero.tsx — toggle TSX / JSX to copy it in either language.

Source code
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";

/**
 * Harsh Dev UI · Developed by Harsh Pandav
 * https://harshpandav.dev · hello@harshpandav.dev
 *
 * HaloHero
 * A hero section ringed by photos. On wide screens the cards orbit two circles
 * hugging the side edges, with the headline framed between them; on phones they
 * drift along an arc above the copy and one below. Each card sways as it travels,
 * and the ring can follow the page's scroll. One animation loop writes transforms
 * straight onto the cards and parks itself while the hero is off-screen, paused,
 * or the visitor prefers reduced motion.
 */

export type HaloHeroDirection = "clockwise" | "counterclockwise";

export type HaloHeroLayout = "split" | "ring";

export type HaloHeroShape = "rounded" | "square" | "circle";

export type HaloHeroHover = "lift" | "zoom" | "none";

export type HaloHeroProps = {
  images: string[];
  children?: ReactNode;
  layout?: HaloHeroLayout;
  shape?: HaloHeroShape;
  hoverEffect?: HaloHeroHover;
  duration?: number;
  direction?: HaloHeroDirection;
  followScroll?: boolean;
  scrollBoost?: number;
  ringWidth?: number;
  ringHeight?: number;
  cardSize?: number;
  tilt?: number;
  dim?: number;
  pauseOnHover?: boolean;
  paused?: boolean;
  background?: string;
  className?: string;
};

const TAU = Math.PI * 2;

const NARROW_WIDTH = 768;

const RADIUS: Record<HaloHeroShape, string> = {
  rounded: "rounded-[10px]",
  square: "rounded-none",
  circle: "rounded-full",
};

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

const seeded = (n: number) => {
  const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
  return x - Math.floor(x);
};

export function HaloHero({
  images,
  children,
  layout = "split",
  shape = "rounded",
  hoverEffect = "lift",
  duration = 60,
  direction = "clockwise",
  followScroll = false,
  scrollBoost = 3,
  ringWidth = 0.9,
  ringHeight = 0.86,
  cardSize = 150,
  tilt = 28,
  dim = 0.15,
  pauseOnHover = false,
  paused = false,
  background = "var(--background, #0d0d0d)",
  className = "",
}: HaloHeroProps) {
  const n = images.length;
  const split = layout === "split";
  const half = Math.ceil(n / 2);
  const cardImages = split
    ? [...images, ...images.slice(half), ...images.slice(0, half)]
    : images;
  const total = cardImages.length;
  const interactive = hoverEffect !== "none";

  const rootRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  const angle = useRef(0);
  const speed = useRef(paused ? 0 : 1);
  const wanted = useRef(paused ? 0 : 1);
  const scrollSign = useRef(1);
  const hovered = useRef(false);
  const wake = useRef<() => void>(() => {});
  const [reduce, setReduce] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduce(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  useEffect(() => {
    const root = rootRef.current;
    if (!root || n === 0) return;

    const dir = direction === "counterclockwise" ? -1 : 1;
    const cards = Array.from({ length: total }, (_, i) => ({
      lean: (seeded(i) * 2 - 1) * tilt,
      phase: seeded(i + 7) * TAU,
      reach: 1 + (seeded(i + 3) - 0.5) * 0.12,
    }));
    if (!followScroll) scrollSign.current = 1;
    let w = 0;
    let h = 0;
    let raf = 0;
    let last = 0;
    let visible = true;
    let boost = 0;
    let lastY = window.scrollY;

    const cardPx = () => cardSize * (w < NARROW_WIDTH ? 0.72 : clamp(w / 1200, 0.55, 1));

    const sizeCards = () => {
      w = root.clientWidth;
      h = root.clientHeight;
      const size = cardPx();
      cardRefs.current.forEach((el, i) => {
        if (!el) return;
        if (shape === "circle") {
          const d = `${Math.round(size * 0.78)}px`;
          el.style.width = d;
          el.style.height = d;
          return;
        }
        const portrait = i % 3 === 2;
        el.style.width = `${Math.round(portrait ? size * 0.7 : size)}px`;
        el.style.height = `${Math.round(portrait ? size : size * 0.66)}px`;
      });
    };

    const place = (el: HTMLDivElement, card: (typeof cards)[number], a: number, x: number, y: number) => {
      const r = card.lean + tilt * 0.5 * Math.sin(a * 2 + card.phase);
      el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) translate(-50%, -50%) rotate(${r.toFixed(2)}deg)`;
    };

    const paint = () => {
      const cy = h / 2;
      const narrow = w < NARROW_WIDTH;
      if (split && !narrow) {
        const ry = ((h * ringHeight) / 2) * 0.9;
        const rx = Math.min(w * 0.2, ry);
        for (let i = 0; i < total; i++) {
          const el = cardRefs.current[i];
          if (!el) continue;
          const card = cards[i];
          const left = i < n;
          const base = angle.current + ((i % n) / n) * TAU;
          const a = left ? base : Math.PI - base;
          const cx = w / 2 + ((left ? -1 : 1) * w * ringWidth) / 2;
          place(el, card, a, cx + Math.cos(a) * rx * card.reach, cy + Math.sin(a) * ry * card.reach);
        }
        return;
      }
      if (narrow) {
        const perRow = split ? n : Math.ceil(n / 2);
        const size = cardPx();
        const spacing = size * 1.12;
        const track = Math.max(perRow * spacing, w + size * 2);
        const travel = (angle.current / TAU) * track;
        const gap = h * ringHeight * 0.4;
        const sag = h * 0.05 * ringWidth;
        const halfW = w / 2;
        for (let i = 0; i < total; i++) {
          const el = cardRefs.current[i];
          if (!el) continue;
          const card = cards[i];
          const bottom = split ? i >= n : i % 2 === 1;
          const j = split ? i % n : Math.floor(i / 2);
          const along = (((j * spacing + (bottom ? -travel : travel)) % track) + track) % track;
          const x = along - size;
          const u = (x - halfW) / halfW;
          const y = bottom ? cy + gap - sag * u * u : cy - gap + sag * u * u;
          const slope = ((bottom ? -2 : 2) * sag * u) / halfW;
          const r = (Math.atan(slope) * 180) / Math.PI + card.lean * 0.35;
          el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) translate(-50%, -50%) rotate(${r.toFixed(2)}deg)`;
        }
        return;
      }
      const cx = w / 2;
      const rx = (w * ringWidth) / 2;
      const ry = (h * ringHeight) / 2;
      for (let i = 0; i < total; i++) {
        const el = cardRefs.current[i];
        if (!el) continue;
        const card = cards[i];
        const a = angle.current + (i / total) * TAU;
        place(el, card, a, cx + Math.cos(a) * rx * card.reach, cy + Math.sin(a) * ry * card.reach);
      }
    };

    const frame = (t: number) => {
      raf = 0;
      const dt = last ? Math.min(0.05, (t - last) / 1000) : 0;
      last = t;
      const resting = reduce || wanted.current === 0 || (pauseOnHover && hovered.current);
      const target = resting ? 0 : scrollSign.current * (1 + boost);
      speed.current += (target - speed.current) * Math.min(1, dt * 4);
      if (Math.abs(target - speed.current) < 0.002) speed.current = target;
      if (boost > 0) {
        boost *= Math.exp(-dt * 2.5);
        if (boost < 0.01) boost = 0;
      }
      angle.current += ((dir * TAU) / Math.max(1, duration)) * speed.current * dt;
      paint();
      if (visible && (speed.current !== 0 || target !== 0)) raf = requestAnimationFrame(frame);
      else last = 0;
    };

    const start = () => {
      if (!raf && visible) raf = requestAnimationFrame(frame);
    };
    wake.current = start;

    const onScroll = () => {
      const y = window.scrollY;
      const dy = y - lastY;
      lastY = y;
      if (dy === 0) return;
      scrollSign.current = dy > 0 ? 1 : -1;
      boost = Math.min(scrollBoost * 4, boost + Math.abs(dy) * 0.02 * scrollBoost);
      start();
    };
    if (followScroll) window.addEventListener("scroll", onScroll, { passive: true });

    const resize = new ResizeObserver(() => {
      sizeCards();
      paint();
    });
    resize.observe(root);

    const onScreen = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) start();
    });
    onScreen.observe(root);

    if (reduce) speed.current = 0;
    sizeCards();
    paint();
    cardRefs.current.forEach((el) => {
      if (el) el.style.opacity = "1";
    });
    start();

    return () => {
      window.removeEventListener("scroll", onScroll);
      resize.disconnect();
      onScreen.disconnect();
      if (raf) cancelAnimationFrame(raf);
      wake.current = () => {};
    };
  }, [n, total, split, shape, duration, direction, followScroll, scrollBoost, ringWidth, ringHeight, cardSize, tilt, pauseOnHover, reduce]);

  useEffect(() => {
    wanted.current = paused ? 0 : 1;
    wake.current();
  }, [paused]);

  return (
    <div
      ref={rootRef}
      onPointerEnter={() => {
        hovered.current = true;
        if (pauseOnHover) wake.current();
      }}
      onPointerLeave={() => {
        hovered.current = false;
        if (pauseOnHover) wake.current();
      }}
      className={`relative isolate overflow-hidden ${className}`}
      style={{ background }}
    >
      <div aria-hidden className="pointer-events-none absolute inset-0">
        {cardImages.map((src, i) => (
          <div
            key={`${src}-${i}`}
            ref={(el) => void (cardRefs.current[i] = el)}
            className={`group/card absolute left-0 top-0 opacity-0 transition-opacity duration-700 will-change-transform ${
              interactive ? "pointer-events-auto hover:z-10" : ""
            }`}
          >
            <div
              className={`relative h-full w-full overflow-hidden ${RADIUS[shape]} shadow-[0_18px_40px_-12px_rgba(0,0,0,0.55)] transition-[transform,box-shadow] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
                hoverEffect === "lift"
                  ? "group-hover/card:scale-[1.12] group-hover/card:shadow-[0_30px_60px_-16px_rgba(0,0,0,0.7)]"
                  : ""
              }`}
            >
              <img
                src={src}
                alt=""
                draggable={false}
                decoding="async"
                className={`h-full w-full object-cover ${
                  hoverEffect === "zoom"
                    ? "transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover/card:scale-[1.18]"
                    : ""
                }`}
              />
              {dim > 0 && (
                <div
                  className={`absolute inset-0 opacity-(--dim) transition-opacity duration-500 ${
                    interactive ? "group-hover/card:opacity-0" : ""
                  }`}
                  style={{ background, "--dim": dim } as CSSProperties}
                />
              )}
            </div>
          </div>
        ))}
      </div>

      <div
        aria-hidden
        className="pointer-events-none absolute inset-0"
        style={{
          background: `radial-gradient(ellipse 36% 32% at 50% 50%, ${background} 45%, transparent 100%)`,
        }}
      />

      <div className="pointer-events-none relative flex h-full w-full flex-col items-center justify-center px-6 text-center [&>*]:pointer-events-auto">
        {children}
      </div>
    </div>
  );
}

Keep in mind

Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.

Contact

Found a bug or an issue? Drop a mail — hello@harshpandav.dev

License & Usage

  • Free to use and modify in personal and commercial projects.
  • Attribution to Harsh Dev UI is appreciated but not required.
  • MIT licensed.

Command Palette

Search for a command to run...