Website Sections
HeroNova
A centred hero. A star bursts behind the title, throws a shock ring, then settles into a slow glow while the words rise in.
Download
- React
- Tailwind CSS v4
- motion
components/ui/hero-nova.tsx
"use client";
import { Fragment, useId, type CSSProperties, type ElementType, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type HeroNovaProps = {
title: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Buttons or links under the text. */
children?: ReactNode;
/** Colours of the burst, from core to rim. */
colors?: [string, string, 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 EASE = [0.16, 1, 0.3, 1] as const;
// A circle of n% of the section width, centred on its parent. Never too small on a phone.
const disc = (n: number): CSSProperties => {
const size = `max(${n}cqw, ${n * 7}px)`;
return { width: size, height: size, left: `calc(${size} / -2)`, top: `calc(${size} / -2)` };
};
/* HeroNova: a centred hero. A star bursts behind the title, throws a shock ring, then settles into a slow glow while the words rise in. */
export function HeroNova({
title,
eyebrow,
subtitle,
children,
colors = ["#ffffff", "#a78bfa", "#22d3ee"],
headingAs: Heading = "h1",
className = "min-h-[36rem] bg-[#07060f] text-white",
}: HeroNovaProps) {
const reduce = useReducedMotion();
const id = useId();
const [core, mid, rim] = colors;
const words = title.split(" ");
const rise = (delay: number) =>
reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.4 } }
: { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.8, delay, ease: EASE } };
return (
<section aria-labelledby={id} className={`@container relative isolate grid place-items-center overflow-hidden ${className}`}>
<div aria-hidden className="absolute top-1/2 left-1/2 -z-10 size-0">
<motion.div
className="absolute rounded-full"
style={{ ...disc(120), background: `radial-gradient(closest-side, ${mid}55, ${rim}22 45%, transparent 72%)` }}
initial={reduce ? false : { scale: 0.1, opacity: 0 }}
animate={{ scale: 1, opacity: 0.85 }}
transition={{ duration: 1.6, ease: EASE }}
/>
<motion.div
className="absolute rounded-full mix-blend-screen"
style={{
...disc(84),
background: `repeating-conic-gradient(from 0deg, ${mid}38 0deg 3deg, transparent 3deg 11deg, ${rim}2e 11deg 13deg, transparent 13deg 23deg)`,
maskImage: "radial-gradient(closest-side, black 8%, transparent 78%)",
WebkitMaskImage: "radial-gradient(closest-side, black 8%, transparent 78%)",
}}
initial={reduce ? false : { scale: 0.2, opacity: 0 }}
animate={reduce ? undefined : { scale: 1, opacity: 1, rotate: 360 }}
transition={{
scale: { duration: 1.6, ease: EASE },
opacity: { duration: 1 },
rotate: { duration: 120, ease: "linear", repeat: Infinity },
}}
/>
<motion.div
className="absolute rounded-full border"
style={{ ...disc(60), borderColor: rim }}
initial={{ scale: 0.05, opacity: reduce ? 0 : 0.9 }}
animate={{ scale: 1.5, opacity: 0 }}
transition={{ duration: 1.6, ease: "easeOut" }}
/>
<motion.div
className="absolute rounded-full blur-2xl"
style={{ ...disc(18), background: `radial-gradient(closest-side, ${core}, ${mid} 40%, transparent)` }}
initial={reduce ? { opacity: 0.45 } : { scale: 3, opacity: 1 }}
animate={reduce ? undefined : { scale: 1, opacity: 0.55 }}
transition={{ duration: 1.4, ease: EASE }}
/>
</div>
<div className="flex max-w-3xl flex-col items-center px-5 py-6 text-center @2xl:py-24">
{eyebrow && (
<motion.p {...rise(0.3)} className="mb-3 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:mb-5 @2xl:text-sm">
{eyebrow}
</motion.p>
)}
<Heading id={id} className="text-[clamp(1.75rem,9cqw,4.5rem)] leading-[1.05] font-semibold tracking-tight text-balance">
<span className="sr-only">{title}</span>
<span aria-hidden>
{words.map((word, i) => (
<Fragment key={i}>
<motion.span {...rise(0.35 + i * 0.07)} className="inline-block">
{word}
</motion.span>{" "}
</Fragment>
))}
</span>
</Heading>
{subtitle && (
<motion.p {...rise(0.7)} className="mt-3 max-w-xl text-sm text-pretty opacity-70 @2xl:mt-5 @2xl:text-lg">
{subtitle}
</motion.p>
)}
{children && (
<motion.div {...rise(0.85)} className="mt-5 flex flex-wrap justify-center gap-3 @2xl:mt-8">
{children}
</motion.div>
)}
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/hero-nova.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/hero-nova-demo.tsx
import { HeroNova } from "@/components/ui/hero-nova"; export default function HeroNovaExample() { return ( <div className="h-full overflow-y-auto"> <HeroNova headingAs="h2" eyebrow="Now in public beta" title="Launch pages that feel alive" subtitle="Motion, type and light that work together from the first second." className="min-h-full bg-[#07060f] text-white" > <a href="#start" className="rounded-full bg-white px-4 py-2 text-sm font-medium text-[#07060f]"> Get started </a> <a href="#tour" className="rounded-full border border-white/25 px-4 py-2 text-sm"> Take the tour </a> </HeroNova> </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. |
| colors | [string, string, string] | ["#ffffff", "#a78bfa", "#22d3ee"] | Colours of the burst, from core to rim. |
| headingAs | ElementType | "h1" | Use h2 if the page already has an h1. |
| className | string | "min-h-[36rem] bg-[#07060f] text-white" | Set the height, background and text colour here. |