Website Sections
HeroSplit
Text on one side, media on the other. The media wipes open, then leans gently toward the pointer. Stacks when narrow.
Download
- React
- Tailwind CSS v4
- motion
components/ui/hero-split.tsx
"use client";
import { useId, useRef, type ElementType, type PointerEvent, type ReactNode } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
type HeroSplitProps = {
title: string;
/** The picture or video side. Fills a 4:3 panel. */
media: ReactNode;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Buttons or links under the text. */
children?: ReactNode;
/** Small card that floats over the corner of the media. */
badge?: ReactNode;
/** Put the media on the left. */
reverse?: boolean;
/** h1 by default. Use h2 if the page already has an h1. */
headingAs?: ElementType;
/** Set the background and text colour here. */
className?: string;
};
const EASE = [0.16, 1, 0.3, 1] as const;
const SPRING = { stiffness: 120, damping: 18 };
/* HeroSplit: text on one side, media on the other. The media wipes open, then leans gently toward the pointer. Stacks when narrow. */
export function HeroSplit({
title,
media,
eyebrow,
subtitle,
children,
badge,
reverse = false,
headingAs: Heading = "h1",
className = "bg-[#0b0a14] text-white",
}: HeroSplitProps) {
const reduce = useReducedMotion();
const id = useId();
const panel = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateY = useSpring(useTransform(x, [-0.5, 0.5], [-5, 5]), SPRING);
const rotateX = useSpring(useTransform(y, [-0.5, 0.5], [4, -4]), SPRING);
const badgeX = useSpring(useTransform(x, [-0.5, 0.5], [-10, 10]), SPRING);
const badgeY = useSpring(useTransform(y, [-0.5, 0.5], [-10, 10]), SPRING);
const move = (e: PointerEvent) => {
const box = panel.current?.getBoundingClientRect();
if (reduce || e.pointerType !== "mouse" || !box) return;
x.set(Math.max(-0.5, Math.min(0.5, (e.clientX - box.left) / box.width - 0.5)));
y.set(Math.max(-0.5, Math.min(0.5, (e.clientY - box.top) / box.height - 0.5)));
};
const leave = () => {
x.set(0);
y.set(0);
};
const rise = (delay: number) =>
reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.4 } }
: { initial: { opacity: 0, y: 22 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.7, delay, ease: EASE } };
const closed = reverse ? "inset(0 0 0 100% round 20px)" : "inset(0 100% 0 0 round 20px)";
return (
<section aria-labelledby={id} onPointerMove={move} onPointerLeave={leave} className={`@container overflow-hidden ${className}`}>
<div className="mx-auto grid max-w-6xl items-center gap-6 px-5 py-6 @2xl:grid-cols-2 @2xl:gap-12 @2xl:py-20">
<div className={reverse ? "@2xl:order-2" : ""}>
{eyebrow && (
<motion.p {...rise(0)} className="mb-3 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:text-sm">
{eyebrow}
</motion.p>
)}
<motion.div {...rise(0.08)}>
<Heading id={id} className="text-[clamp(1.6rem,8cqw,2.5rem)] leading-[1.06] font-semibold tracking-tight text-balance @2xl:text-[clamp(2rem,5cqw,3.75rem)]">
{title}
</Heading>
</motion.div>
{subtitle && (
<motion.p {...rise(0.16)} className="mt-3 max-w-md text-sm text-pretty opacity-70 @2xl:mt-5 @2xl:text-lg">
{subtitle}
</motion.p>
)}
{children && (
<motion.div {...rise(0.24)} className="mt-5 flex flex-wrap gap-3 @2xl:mt-8">
{children}
</motion.div>
)}
</div>
<div ref={panel} className="relative [perspective:1000px]">
<motion.div style={reduce ? undefined : { rotateX, rotateY }}>
<motion.div
className="aspect-[4/3] overflow-hidden rounded-[20px] [&>*]:size-full [&>*]:object-cover"
initial={reduce ? { opacity: 0 } : { clipPath: closed }}
animate={reduce ? { opacity: 1 } : { clipPath: "inset(0 0% 0 0% round 20px)" }}
transition={reduce ? { duration: 0.4 } : { duration: 1.1, delay: 0.15, ease: EASE }}
>
{media}
</motion.div>
</motion.div>
{badge && (
<motion.div
className={`absolute bottom-3 @2xl:-bottom-4 ${reverse ? "right-3 @2xl:-right-4" : "left-3 @2xl:-left-4"}`}
style={reduce ? undefined : { x: badgeX, y: badgeY }}
>
<motion.div {...rise(0.9)}>{badge}</motion.div>
</motion.div>
)}
</div>
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/hero-split.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/hero-split-demo.tsx
import { HeroSplit } from "@/components/ui/hero-split"; export default function HeroSplitExample() { return ( <div className="h-full overflow-y-auto"> <HeroSplit headingAs="h2" eyebrow="Field notes" title="Photos that tell the whole trip" subtitle="Shared albums with maps, notes and the stories behind each shot." className="min-h-full bg-[#0b0a14] text-white" media={<img src="/demo/dawn.jpg" alt="Hills at dawn" />} badge={ <p className="rounded-xl border border-white/15 bg-[#17142b]/90 px-3 py-2 text-xs text-white backdrop-blur"> <strong className="block text-sm">2,400</strong> albums this week </p> } > <a href="#start" className="rounded-lg bg-white px-4 py-2 text-sm font-medium text-[#0b0a14]"> Start an album </a> </HeroSplit> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | — | The big line. |
| media | ReactNode | — | The picture or video side. Fills a 4:3 panel. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| children | ReactNode | — | Buttons or links under the text. |
| badge | ReactNode | — | Small card that floats over the corner of the media. |
| reverse | boolean | false | Put the media on the left. |
| headingAs | ElementType | "h1" | Use h2 if the page already has an h1. |
| className | string | "bg-[#0b0a14] text-white" | Set the background and text colour here. |