freeNew196 lines · framer-motion
Hero Split Product
The pitch on one side, a live mock on the other. A split SaaS hero: copy and two CTAs on the left, a hand-drawn CSS/SVG product mock (browser chrome, bar chart, sparkline, a floating stat card) on the right that tilts toward the pointer.
- Use it for
- AI dashboards, analytics tools or any SaaS product that wants to show a believable screenshot without a real one.
- Themes
- Light + dark
- License
- Commercial use
Live
New · Workspace
Your product, finally legible
A split hero built for SaaS: the pitch on one side, a live-feeling product mock on the other.
On track
tsx
"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Split SaaS hero: copy and CTAs on the left, a hand-drawn CSS/SVG product
* mock (fake browser chrome, bar chart, sparkline) on the right that tilts
* gently toward the pointer.
*/
export interface HeroSplitProductProps {
eyebrow?: string;
headline?: string;
subcopy?: string;
primaryCta?: string;
secondaryCta?: string;
/** Accent used by the mock dashboard's chart and trend pill. */
accent?: "signal" | "info" | "lime";
/** Label on the small accent pill above the chart. */
trendLabel?: string;
/** Label on the floating status card. */
statLabel?: string;
/** Value on the floating status card. */
statValue?: string;
/** Tilt the product card toward the pointer. */
tilt?: boolean;
className?: string;
}
const BARS = [38, 62, 48, 80, 54, 70, 42];
const ACCENT_CLASS: Record<string, string> = {
signal: "bg-signal text-signal-foreground",
info: "bg-info text-white",
lime: "bg-lime text-foreground",
};
export function HeroSplitProduct({
eyebrow = "New · Workspace",
headline = "Your product, finally legible",
subcopy = "A split hero built for SaaS: the pitch on one side, a live-feeling product mock on the other.",
primaryCta = "Start free",
secondaryCta = "Book a demo",
accent = "signal",
trendLabel = "On track",
statLabel = "Status",
statValue = "All systems normal",
tilt = true,
className,
}: HeroSplitProductProps) {
const reduce = useReducedMotionSafe();
const mx = useMotionValue(0.5);
const my = useMotionValue(0.5);
const rx = useSpring(useTransform(my, [0, 1], [8, -8]), { stiffness: 180, damping: 20 });
const ry = useSpring(useTransform(mx, [0, 1], [-10, 10]), { stiffness: 180, damping: 20 });
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!tilt || reduce) return;
const r = e.currentTarget.getBoundingClientRect();
mx.set((e.clientX - r.left) / r.width);
my.set((e.clientY - r.top) / r.height);
};
const onLeave = () => {
mx.set(0.5);
my.set(0.5);
};
return (
<section className={cn("relative isolate overflow-hidden bg-background", className)}>
<div className="relative mx-auto grid min-h-[30rem] max-w-6xl grid-cols-1 items-center gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1fr_1.1fr] lg:py-20">
<div className="text-center lg:text-left">
<motion.span
initial={reduce ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground"
>
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
{eyebrow}
</motion.span>
<motion.h1
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.1 }}
className="mt-5 font-display text-[clamp(2.3rem,5.4vw,4.1rem)] font-extrabold leading-[0.98] tracking-[-0.04em]"
>
{headline}
</motion.h1>
<motion.p
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.25 }}
className="mx-auto mt-4 max-w-md text-lg leading-relaxed text-muted-foreground lg:mx-0"
>
{subcopy}
</motion.p>
<motion.div
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.35 }}
className="mt-7 flex flex-wrap items-center justify-center gap-3 lg:justify-start"
>
<a href="#" className="inline-flex h-12 items-center rounded-full bg-primary px-6 text-[15px] font-semibold text-primary-foreground transition hover:opacity-90">
{primaryCta} →
</a>
<a href="#" className="inline-flex h-12 items-center rounded-full border-2 border-foreground px-6 text-[15px] font-semibold transition hover:bg-foreground hover:text-background">
{secondaryCta}
</a>
</motion.div>
</div>
<motion.div
onPointerMove={onMove}
onPointerLeave={onLeave}
style={{ perspective: 1000 }}
initial={reduce ? false : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.15 }}
className="relative mx-auto w-full max-w-lg"
>
<motion.div
style={{ rotateX: rx, rotateY: ry, transformStyle: "preserve-3d" }}
className="overflow-hidden rounded-2xl border-2 border-foreground bg-card shadow-[10px_10px_0_0_var(--foreground)]"
>
<div className="flex items-center gap-1.5 border-b border-border bg-muted px-4 py-2.5">
<span className="size-2.5 rounded-full bg-destructive" />
<span className="size-2.5 rounded-full bg-warn" />
<span className="size-2.5 rounded-full bg-ok" />
<span className="ml-3 h-5 flex-1 max-w-[12rem] rounded-full bg-background/60" />
</div>
<div className="grid grid-cols-[4.5rem_1fr] gap-0">
<div className="space-y-2 border-r border-border bg-muted/50 p-3">
{[0, 1, 2, 3].map((i) => (
<div key={i} className={cn("h-6 rounded-md", i === 0 ? ACCENT_CLASS[accent] : "bg-background")} />
))}
</div>
<div className="p-4">
<div className="flex items-center justify-between">
<div className="h-3 w-24 rounded-full bg-muted" />
<span className={cn("rounded-full px-2.5 py-1 font-mono text-[10px] font-bold", ACCENT_CLASS[accent])}>{trendLabel}</span>
</div>
<div className="mt-4 flex items-end gap-2 h-24">
{BARS.map((h, i) => (
<motion.div
key={i}
className={cn("flex-1 rounded-t-sm", i === 3 ? ACCENT_CLASS[accent] : "bg-muted")}
initial={reduce ? { height: `${h}%` } : { height: "4%" }}
animate={{ height: `${h}%` }}
transition={{ duration: 0.6, delay: 0.5 + i * 0.05, ease: "easeOut" }}
/>
))}
</div>
<svg viewBox="0 0 200 40" className="mt-4 h-10 w-full overflow-visible">
<path
d="M0 30 L28 18 L56 24 L84 8 L112 16 L140 6 L168 14 L200 4"
fill="none"
stroke="var(--border)"
strokeWidth="2"
/>
<motion.path
d="M0 30 L28 18 L56 24 L84 8 L112 16 L140 6 L168 14 L200 4"
fill="none"
className={accent === "signal" ? "stroke-signal" : accent === "info" ? "stroke-info" : "stroke-lime"}
strokeWidth="2.5"
strokeLinecap="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.1, delay: 0.6, ease: "easeInOut" }}
/>
</svg>
</div>
</div>
</motion.div>
<div
className={cn("absolute -bottom-6 -left-6 hidden max-w-[9.5rem] rounded-xl border-2 border-foreground bg-card px-4 py-3 shadow-[6px_6px_0_0_var(--foreground)] sm:block")}
aria-hidden
>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{statLabel}</p>
<p className="font-display text-base font-extrabold leading-snug">{statValue}</p>
</div>
</motion.div>
</div>
</section>
);
}
export default HeroSplitProduct;
How it behaves
- →The whole mock card is drawn from divs and a small inline SVG (no image asset): fake traffic-light window controls, a sidebar, animated bar chart columns and a sparkline whose stroke draws in on mount.
- →When tilt is on, the card rotates toward the pointer using useMotionValue + a damped useSpring (rotateX/rotateY), and eases back to flat on pointer leave.
- →accent recolours the active sidebar row, the stat pill and the sparkline stroke between the signal, info and lime tokens.
- →Bar heights and the sparkline path animate in once on mount; prefers-reduced-motion renders them already at their final state instead of animating.
Accessibility
- ✓The product mock, its chart bars and the sparkline are aria-hidden; only the eyebrow, heading, copy and two real <a> CTAs are exposed to assistive tech.
- ✓Layout stacks to a single centred column under 1024px, with the mock card below the copy.
- ✓CTAs keep the design system's visible focus ring; the tilt effect is purely decorative and never traps focus.
- ✓Honours prefers-reduced-motion.