Website Sections
HeroOrbit
A centred hero with three rings around the text. Logos or avatars circle on them and stay upright. Hover pauses them.
Download
- React
- Tailwind CSS v4
components/ui/hero-orbit.tsx
"use client";
import { useId, type CSSProperties, type ElementType, type ReactNode } from "react";
type HeroOrbitProps = {
title: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Buttons or links under the text. */
children?: ReactNode;
/** Small logos, avatars or icons. They are spread over the 3 rings. Decoration: hidden from screen readers. */
orbiters?: ReactNode[];
/** Seconds for the inner ring to go round once. Outer rings are slower. */
duration?: number;
/** Soft patch behind the text that keeps it readable. Match it to the background. */
veil?: string;
/** h1 by default. Use h2 if the page already has an h1. */
headingAs?: ElementType;
/** Set the height, background and text colour here. */
className?: string;
};
const RINGS = [
{ size: 50, min: 290, pace: 1, way: "normal" },
{ size: 72, min: 420, pace: 1.6, way: "reverse" },
{ size: 94, min: 550, pace: 2.3, way: "normal" },
] as const;
const PAUSE = "group-hover/orbit:[animation-play-state:paused] motion-reduce:[animation-play-state:paused]";
// Longhand, not the animation shorthand: inline it would reset play-state and the pause classes would lose.
const spin = (seconds: number, way: "normal" | "reverse"): CSSProperties => ({
animationName: "hero-orbit-turn",
animationDuration: `${seconds}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationDirection: way,
});
/* HeroOrbit: a centred hero with three rings around the text. Logos or avatars circle on them and stay upright. Hover pauses them. */
export function HeroOrbit({
title,
eyebrow,
subtitle,
children,
orbiters = [],
duration = 28,
veil = "#0b0a14",
headingAs: Heading = "h1",
className = "min-h-[36rem] bg-[#0b0a14] text-white",
}: HeroOrbitProps) {
const id = useId();
return (
<section aria-labelledby={id} className={`group/orbit @container relative isolate grid place-items-center overflow-hidden ${className}`}>
<style>{`@keyframes hero-orbit-turn { to { transform: rotate(360deg); } }`}</style>
<div aria-hidden className="absolute top-1/2 left-1/2 -z-10">
{RINGS.map((ring, r) => {
const onRing = orbiters.filter((_, i) => i % RINGS.length === r);
const seconds = duration * ring.pace;
const size = `max(${ring.size}cqw, ${ring.min}px)`;
return (
<div
key={ring.size}
className={`absolute rounded-full border border-current/15 ${PAUSE}`}
style={{ width: size, height: size, left: `calc(${size} / -2)`, top: `calc(${size} / -2)`, ...spin(seconds, ring.way) }}
>
{onRing.map((node, i) => {
const angle = (360 / onRing.length) * i + r * 40;
return (
<div
key={i}
className="absolute top-1/2 left-1/2 size-0"
style={{ transform: `rotate(${angle}deg) translateY(calc(${size} / -2)) rotate(${-angle}deg)` }}
>
<div className="absolute w-max -translate-x-1/2 -translate-y-1/2">
<div className={PAUSE} style={spin(seconds, ring.way === "normal" ? "reverse" : "normal")}>
{node}
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
<div className="relative flex max-w-2xl flex-col items-center px-5 py-6 text-center">
<div aria-hidden className="absolute -inset-x-16 -inset-y-10 -z-10" style={{ background: `radial-gradient(closest-side, ${veil} 55%, transparent)` }} />
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:mb-4 @2xl:text-sm">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.6rem,8cqw,4rem)] leading-[1.08] font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 max-w-lg text-sm text-pretty opacity-70 @2xl:mt-5 @2xl:text-lg">{subtitle}</p>}
{children && <div className="mt-4 flex flex-wrap justify-center gap-3 @2xl:mt-8">{children}</div>}
</div>
</section>
);
}
1. Paste the Code tab into
components/ui/hero-orbit.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/hero-orbit-demo.tsx
import { Bolt, Cloud, Code, Globe, Heart, Music, Rocket, Star, Zap } from "lucide-react"; import { HeroOrbit } from "@/components/ui/hero-orbit"; const ICONS = [Rocket, Code, Star, Globe, Zap, Music, Heart, Cloud, Bolt]; export default function HeroOrbitExample() { return ( <div className="h-full overflow-y-auto"> <HeroOrbit headingAs="h2" eyebrow="120 tools, one plan" title="Everything your team uses, in orbit" subtitle="Connect once and let the data move on its own." className="min-h-full bg-[#0b0a14] text-white" orbiters={ICONS.map((Icon, i) => ( <span key={i} className="grid size-10 place-items-center rounded-full border border-white/15 bg-[#1a1730] text-[#a78bfa]"> <Icon className="size-4" /> </span> ))} > <a href="#connect" className="rounded-full bg-[#a78bfa] px-4 py-2 text-sm font-medium text-[#0b0a14]"> Connect your stack </a> </HeroOrbit> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | — | The big line. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| children | ReactNode | — | Buttons or links under the text. |
| orbiters | ReactNode[] | [] | Small logos, avatars or icons, spread over the 3 rings. Decoration: hidden from screen readers. |
| duration | number | 28 | Seconds for the inner ring to go round once. Outer rings are slower. |
| veil | string | "#0b0a14" | Soft patch behind the text that keeps it readable. Match it to the background. |
| headingAs | ElementType | "h1" | Use h2 if the page already has an h1. |
| className | string | "min-h-[36rem] bg-[#0b0a14] text-white" | Set the height, background and text colour here. |