Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:37Get Lifetime →
loopkit
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.