Showcase Hero with Three Drifting Columns of App Screens

A light hero for a product with a lot to show. Behind the headline, three columns of drawn mobile screens (wallets, music players, maps, inboxes, calendars) travel endlessly at three different speeds, the middle one in the opposite direction. The whole wall is tipped back and turned in 3D so it reads as a surface receding into the page, and it leans a few degrees toward the pointer. A soft pool of the page colour sits behind the words to keep them readable. On phones the wall is narrower and the buttons stack.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";

type Tone = keyof typeof tones;
type Kind = "stat" | "list" | "player" | "calendar" | "chat" | "wallet" | "map" | "ring" | "product";
type Screen = { kind: Kind; tone: Tone; title: string };

const tones = {
  rose: { solid: "bg-rose-500", soft: "bg-rose-100", text: "text-rose-600", wash: "from-rose-400 to-orange-300", hex: "#f43f5e" },
  sky: { solid: "bg-sky-500", soft: "bg-sky-100", text: "text-sky-600", wash: "from-sky-400 to-indigo-400", hex: "#0ea5e9" },
  emerald: { solid: "bg-emerald-500", soft: "bg-emerald-100", text: "text-emerald-600", wash: "from-emerald-400 to-teal-300", hex: "#10b981" },
  amber: { solid: "bg-amber-400", soft: "bg-amber-100", text: "text-amber-600", wash: "from-amber-300 to-rose-300", hex: "#f59e0b" },
  violet: { solid: "bg-violet-500", soft: "bg-violet-100", text: "text-violet-600", wash: "from-violet-400 to-fuchsia-400", hex: "#8b5cf6" },
  slate: { solid: "bg-slate-900", soft: "bg-slate-100", text: "text-slate-900", wash: "from-slate-700 to-slate-900", hex: "#0f172a" },
};

/** Three columns of screens. `seconds` is the time to travel one full list; `down` reverses the direction. */
const columns: { seconds: number; down: boolean; screens: Screen[] }[] = [
  {
    seconds: 46,
    down: false,
    screens: [
      { kind: "wallet", tone: "violet", title: "Wallet" },
      { kind: "stat", tone: "emerald", title: "Weekly sales" },
      { kind: "chat", tone: "sky", title: "Mara Ellison" },
      { kind: "calendar", tone: "rose", title: "March" },
      { kind: "product", tone: "amber", title: "Oak desk lamp" },
    ],
  },
  {
    seconds: 34,
    down: true,
    screens: [
      { kind: "player", tone: "rose", title: "Low Tide" },
      { kind: "map", tone: "emerald", title: "Courier is 4 min away" },
      { kind: "ring", tone: "amber", title: "Today" },
      { kind: "list", tone: "violet", title: "Inbox" },
      { kind: "stat", tone: "sky", title: "Active members" },
    ],
  },
  {
    seconds: 54,
    down: false,
    screens: [
      { kind: "ring", tone: "sky", title: "Focus" },
      { kind: "product", tone: "rose", title: "Trail runner 2" },
      { kind: "list", tone: "emerald", title: "Tasks" },
      { kind: "wallet", tone: "slate", title: "Cards" },
      { kind: "player", tone: "violet", title: "Night Bus" },
    ],
  },
];

const rows = [
  { who: "Idris Vale", what: "Sent the signed brief", tag: "New" },
  { who: "Coralie Hart", what: "Moved launch to Thursday", tag: "2h" },
  { who: "Tomas Reyes", what: "Left three comments", tag: "5h" },
  { who: "Wen Li", what: "Shared the final export", tag: "1d" },
];
const bars = [40, 62, 48, 78, 56, 92, 70];
const busy = [2, 8, 9, 15, 17, 23, 24, 27];

function AppScreen({ screen }: { screen: Screen }) {
  const t = tones[screen.tone];
  return (
    <div className="pb-5">
      <div className="rounded-[28px] border border-slate-900/10 bg-white p-4 shadow-xl shadow-slate-900/10">
        <div className="flex items-center justify-between text-[10px] font-semibold text-slate-400">
          <span>9:41</span>
          <span className="flex gap-1">
            <span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
            <span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
            <span className="h-1.5 w-3 rounded-full bg-slate-400" />
          </span>
        </div>
        <p className="mt-3 text-base font-semibold text-slate-900">{screen.title}</p>

        {screen.kind === "stat" && (
          <>
            <p className="mt-1 text-3xl font-semibold tracking-tight text-slate-900">$12,480</p>
            <p className={`text-xs font-semibold ${t.text}`}>Up 14% on last week</p>
            <div className="mt-4 flex h-24 items-end gap-2">
              {bars.map((h, i) => (
                <span key={i} className={`flex-1 rounded-md ${i === 5 ? t.solid : t.soft}`} style={{ height: `${h}%` }} />
              ))}
            </div>
          </>
        )}

        {screen.kind === "list" && (
          <ul className="mt-3 space-y-3">
            {rows.map((r, i) => (
              <li key={r.who} className="flex items-center gap-3">
                <span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${i === 0 ? `${t.solid} text-white` : `${t.soft} ${t.text}`}`}>{r.who[0]}</span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium text-slate-900">{r.who}</span>
                  <span className="block truncate text-xs text-slate-500">{r.what}</span>
                </span>
                <span className="text-[10px] font-semibold text-slate-400">{r.tag}</span>
              </li>
            ))}
          </ul>
        )}

        {screen.kind === "player" && (
          <>
            <div className={`relative mt-3 aspect-square overflow-hidden rounded-2xl bg-gradient-to-br ${t.wash}`}>
              <span className="absolute -bottom-8 -left-8 h-32 w-32 rounded-full bg-white/30" />
              <span className="absolute right-6 top-6 h-14 w-14 rounded-full border-4 border-white/50" />
            </div>
            <p className="mt-3 text-xs text-slate-500">The Paper Lanterns</p>
            <div className="mt-3 h-1 rounded-full bg-slate-100">
              <div className={`h-1 w-3/5 rounded-full ${t.solid}`} />
            </div>
            <div className="mt-4 flex items-center justify-center gap-6 text-slate-900">
              <svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4"><path d="M15 4v12L7 10zM5 4h2v12H5z" /></svg>
              <span className={`flex h-11 w-11 items-center justify-center rounded-full text-white ${t.solid}`}>
                <svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4"><path d="M6 4h3v12H6zM11 4h3v12h-3z" /></svg>
              </span>
              <svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4"><path d="M5 4v12l8-6zM13 4h2v12h-2z" /></svg>
            </div>
          </>
        )}

        {screen.kind === "calendar" && (
          <div className="mt-3 grid grid-cols-7 gap-1 text-center text-[10px] font-medium text-slate-500">
            {Array.from({ length: 28 }, (_, i) => (
              <span key={i} className={`flex aspect-square items-center justify-center rounded-lg ${i === 15 ? `${t.solid} text-white` : busy.includes(i) ? `${t.soft} ${t.text}` : ""}`}>
                {i + 1}
              </span>
            ))}
          </div>
        )}

        {screen.kind === "chat" && (
          <div className="mt-3 space-y-2 text-xs">
            <p className="w-4/5 rounded-2xl rounded-bl-md bg-slate-100 px-3 py-2 text-slate-700">Are we still on for the walkthrough at three?</p>
            <p className={`ml-auto w-3/4 rounded-2xl rounded-br-md px-3 py-2 text-white ${t.solid}`}>Yes. I will bring the new flows.</p>
            <p className="w-2/3 rounded-2xl rounded-bl-md bg-slate-100 px-3 py-2 text-slate-700">Perfect, see you then.</p>
            <div className="flex items-center gap-2 pt-2">
              <span className="h-9 flex-1 rounded-full bg-slate-100" />
              <span className={`h-9 w-9 rounded-full ${t.solid}`} />
            </div>
          </div>
        )}

        {screen.kind === "wallet" && (
          <>
            <div className={`mt-3 rounded-2xl bg-gradient-to-br p-4 text-white ${t.wash}`}>
              <span className="block h-6 w-9 rounded-md bg-white/40" />
              <p className="mt-6 font-mono text-sm tracking-widest">•••• 4417</p>
              <p className="mt-1 text-[10px] uppercase tracking-widest text-white/80">Juno Castell</p>
            </div>
            <ul className="mt-4 space-y-3 text-sm">
              <li className="flex justify-between"><span className="text-slate-600">Greenhouse Cafe</span><span className="font-semibold text-slate-900">-$8.40</span></li>
              <li className="flex justify-between"><span className="text-slate-600">Salary</span><span className={`font-semibold ${t.text}`}>+$3,150</span></li>
            </ul>
          </>
        )}

        {screen.kind === "map" && (
          <>
            <div className={`relative mt-3 h-40 overflow-hidden rounded-2xl ${t.soft}`}>
              <svg viewBox="0 0 240 160" preserveAspectRatio="none" className="absolute inset-0 h-full w-full">
                <path d="M0 50h240M0 110h240M70 0v160M170 0v160" stroke="#fff" strokeWidth="10" />
                <path d="M30 130C80 130 70 60 120 60s60 -30 96 -34" fill="none" stroke={t.hex} strokeWidth="4" strokeLinecap="round" strokeDasharray="1 9" />
                <circle cx="30" cy="130" r="7" fill="#0f172a" />
                <circle cx="216" cy="26" r="9" fill={t.hex} stroke="#fff" strokeWidth="4" />
              </svg>
            </div>
            <div className="mt-3 flex items-center gap-3">
              <span className={`h-9 w-9 rounded-full ${t.solid}`} />
              <span className="flex-1 text-xs text-slate-500">Bao, on a blue bike</span>
              <span className="rounded-full bg-slate-900 px-3 py-1.5 text-[10px] font-semibold text-white">Call</span>
            </div>
          </>
        )}

        {screen.kind === "ring" && (
          <div className="mt-2 flex flex-col items-center">
            <svg viewBox="0 0 120 120" className="h-36 w-36 -rotate-90">
              <circle cx="60" cy="60" r="48" fill="none" stroke="#f1f5f9" strokeWidth="12" />
              <circle cx="60" cy="60" r="48" fill="none" stroke={t.hex} strokeWidth="12" strokeLinecap="round" strokeDasharray="301.6" strokeDashoffset="84" />
            </svg>
            <p className="-mt-24 text-2xl font-semibold text-slate-900">72%</p>
            <p className="text-[10px] font-medium text-slate-500">of your goal</p>
            <div className="mt-14 grid w-full grid-cols-2 gap-2 text-xs">
              <span className={`rounded-xl px-3 py-2 font-semibold ${t.soft} ${t.text}`}>4h 20m deep</span>
              <span className="rounded-xl bg-slate-100 px-3 py-2 font-semibold text-slate-600">3 breaks</span>
            </div>
          </div>
        )}

        {screen.kind === "product" && (
          <>
            <div className={`relative mt-3 h-36 overflow-hidden rounded-2xl ${t.soft}`}>
              <span className={`absolute bottom-0 left-1/2 h-24 w-20 -translate-x-1/2 rounded-t-full ${t.solid}`} />
              <span className="absolute bottom-0 left-1/2 h-2 w-32 -translate-x-1/2 rounded-full bg-slate-900/10" />
            </div>
            <div className="mt-3 flex items-center justify-between">
              <span className="text-lg font-semibold text-slate-900">$84</span>
              <span className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white">Add to bag</span>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

export default function ShowcaseHeroWithThreeDriftingColumnsOfAppScreens() {
  const reduce = !!useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const [onScreen, setOnScreen] = useState(true);

  // The whole wall leans a few degrees toward the pointer.
  const mx = useMotionValue(0);
  const my = useMotionValue(0);
  const sx = useSpring(mx, { stiffness: 50, damping: 16 });
  const sy = useSpring(my, { stiffness: 50, damping: 16 });
  const leanY = useTransform(sx, [-0.5, 0.5], [-5, 5]);
  const leanX = useTransform(sy, [-0.5, 0.5], [4, -4]);

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const onMove = (e: React.PointerEvent<HTMLElement>) => {
    if (reduce) return;
    const r = e.currentTarget.getBoundingClientRect();
    mx.set((e.clientX - r.left) / r.width - 0.5);
    my.set((e.clientY - r.top) / r.height - 0.5);
  };
  const onLeave = () => {
    mx.set(0);
    my.set(0);
  };

  const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2";

  return (
    <section ref={ref} data-paused={!onScreen} onPointerMove={onMove} onPointerLeave={onLeave} className="shd-root relative flex h-[720px] items-center overflow-hidden bg-slate-50 px-6 text-slate-900 sm:h-[820px]">
      <style>{`
        @keyframes showcase-hero-with-three-drifting-columns-of-app-screens-drift { from { transform: translate3d(0, 16.6667%, 0); } to { transform: translate3d(0, -16.6667%, 0); } }
        .shd-col { animation: showcase-hero-with-three-drifting-columns-of-app-screens-drift linear infinite; will-change: transform; }
        .shd-pool { background-image: radial-gradient(ellipse 110% 46% at 50% 50%, rgba(248,250,252,1) 45%, rgba(248,250,252,0.88) 64%, rgba(248,250,252,0) 100%); }
        @media (min-width: 640px) { .shd-pool { background-image: radial-gradient(ellipse 50% 46% at 50% 50%, rgba(248,250,252,1) 42%, rgba(248,250,252,0.88) 62%, rgba(248,250,252,0) 100%); } }
        .shd-root[data-paused="true"] .shd-col { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) { .shd-col { animation: none; } }
      `}</style>

      {/* The wall of screens, tipped back and turned like a table of prints. */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ perspective: "1600px" }}>
        <motion.div
          className="absolute inset-0"
          style={{ rotateX: leanX, rotateY: leanY }}
          initial={reduce ? false : { opacity: 0, scale: 1.35 }}
          animate={{ opacity: 1, scale: 1 }}
          transition={{ type: "spring", stiffness: 40, damping: 14, opacity: { duration: 0.6 } }}
        >
          <div className="absolute left-1/2 top-1/2 grid w-[780px] grid-cols-3 gap-4 sm:w-[1180px] sm:gap-6" style={{ transform: "translate(-50%, -50%) rotateX(50deg) rotateZ(-24deg)" }}>
            {columns.map((col, ci) => (
              <div key={ci} className="shd-col" style={{ animationDuration: `${col.seconds}s`, animationDirection: col.down ? "reverse" : "normal" }}>
                {[0, 1, 2].map((copy) => col.screens.map((s) => <AppScreen key={`${copy}-${s.title}`} screen={s} />))}
              </div>
            ))}
          </div>
        </motion.div>
      </div>

      {/* A soft white pool behind the words, and fades at the top and bottom edges. */}
      <div aria-hidden="true" className="shd-pool pointer-events-none absolute inset-0" />
      <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-slate-50 to-transparent" />
      <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-slate-50 to-transparent" />

      <motion.div
        className="relative mx-auto max-w-2xl text-center"
        initial={reduce ? false : { opacity: 0, y: 30, filter: "blur(10px)" }}
        animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
        transition={{ duration: 0.9, delay: 0.25, ease: [0.2, 0.8, 0.2, 1] }}
      >
        <p className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-3.5 py-1.5 text-sm font-medium text-slate-700 shadow-sm">
          <span className="flex h-5 items-center rounded-full bg-slate-900 px-2 text-[11px] font-semibold text-white">New</span>
          Lumen Kit 4, now with 240 screens
        </p>
        <h1 className="mt-6 text-5xl font-semibold leading-[1.02] tracking-tight sm:text-7xl">Every screen your app needs, already drawn.</h1>
        <p className="mx-auto mt-6 max-w-lg text-lg leading-relaxed text-slate-600">Wallets, players, maps, inboxes and checkouts in one consistent kit. Copy a screen, change the colour, ship it this week.</p>
        <div className="mt-8 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
          <a href="#" className={`inline-flex items-center justify-center rounded-full bg-slate-900 px-7 py-3.5 text-sm font-semibold text-white shadow-lg shadow-slate-900/25 transition hover:bg-slate-700 ${ring}`}>
            Get the kit for $79
          </a>
          <a href="#" className={`inline-flex items-center justify-center rounded-full border border-slate-300 bg-white px-7 py-3.5 text-sm font-semibold text-slate-900 transition hover:border-slate-500 ${ring}`}>
            Browse all 240 screens
          </a>
        </div>
      </motion.div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/showcase-hero-with-three-drifting-columns-of-app-screens.json

When to use it

How to customize it

Accessibility

Questions

How does the endless vertical scroll loop without a jump?

Each column renders its list three times. A keyframe slides the column by exactly one third of its own height, which is one full list, and then restarts. Because the list repeats, the frame at the end is identical to the frame at the start. The gap under each screen is padding inside the item, so the three copies are exactly the same height.

How is the 3D wall made?

The parent sets a CSS perspective. The grid of columns is rotated back on the X axis and then turned on the Z axis, which gives the angled tabletop look. The columns keep animating in their own flat space, so the drift follows the tilt automatically.

Will this slow the page down?

Each column is one element moved with `translate3d`, so the browser moves three layers and repaints nothing. The animation pauses when the hero leaves the screen.

How do I use real screenshots?

Replace the contents of `AppScreen` with an image inside the same rounded, bordered card. Give every image an explicit width and height so the column height is known before the images load, otherwise the loop point shifts.

Can the columns pause when someone hovers?

Yes. Remove `pointer-events-none` from the wall and add a rule such as `.shd-root:hover .shd-col { animation-play-state: paused; }` to the style element.

Related: Three Column Testimonial Wall with Vertical Scroll, App Landing Hero with Phone Mockup, Coverflow Gallery with 3D Perspective and Autoplay.

More hero sections or all free UI components. Start for free to have a full site built around it.

More in Hero Sections

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap