Responsive Showcase with Laptop and Phone Device Mockups

A showcase section with two device frames: a laptop drawn as an SVG and a phone built from rounded boxes. Each screen is a real container, so what appears inside is ordinary markup, not a screenshot. The demo renders the same small studio website in both, at a desktop layout in the laptop and a stacked layout in the phone, and each page scrolls down and back by itself as if someone were browsing. The phone overlaps the laptop's corner and floats gently. Beside the devices are a heading, three points and a button. On phones the text comes first and the devices scale down together underneath.

Code

// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/macbook-pro.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import type { CSSProperties, ReactNode } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowRight, Check } from "lucide-react";

const classes = [
  { name: "Reformer basics", time: "Mon 7:00", spots: "3 left", tone: "bg-rose-200" },
  { name: "Slow flow", time: "Tue 18:30", spots: "6 left", tone: "bg-amber-200" },
  { name: "Core and breath", time: "Thu 12:15", spots: "Full", tone: "bg-emerald-200" },
  { name: "Weekend reset", time: "Sat 9:00", spots: "2 left", tone: "bg-sky-200" },
];

const points = ["Looks right at every width, with no extra work", "Bookings and payments built in", "Live on your own domain the same day"];

/** A laptop frame drawn in SVG. Whatever you pass as children fills the screen. */
function Laptop({ children }: { children: ReactNode }) {
  return (
    <div className="relative aspect-[650/400] w-full">
      <svg viewBox="0 0 650 400" fill="none" className="absolute inset-0 h-full w-full drop-shadow-[0_30px_40px_rgba(15,23,42,0.28)]" aria-hidden="true">
        <path fill="#a4a5a7" d="M79.56,13.18h491.32c7.23,0,13.1,5.87,13.1,13.1v336.61H66.46V26.28c0-7.23,5.87-13.1,13.1-13.1Z" />
        <path fill="#222" d="M79.96,14.24h490.45c6.83,0,12.37,5.54,12.37,12.37v336.28H67.59V26.6c0-6.83,5.54-12.37,12.37-12.37Z" />
        <path fill="#000" d="M570.25,15.74H80.34c-6.12,0-11.08,4.96-11.08,11.08v336.07h512.08V26.82c0-6.12-4.96-11.08-11.08-11.08Z" />
        <rect fill="#1d1d1d" x="69.09" y="350.51" width="512.11" height="12.48" />
        <path fill="#acadaf" d="M19.04,362.77h611.92v10.39c0,5.95-4.83,10.79-10.79,10.79H29.83c-5.95,0-10.79-4.83-10.79-10.79v-10.39h0Z" />
        <polygon fill="#b9b9bb" points="600.06 385.39 567.29 385.39 565.84 383.95 601.82 383.95 600.06 385.39" />
        <polygon fill="#b9b9bb" points="82.64 385.39 49.87 385.39 48.43 383.95 84.41 383.95 82.64 385.39" />
        <path fill="#8f9091" d="M278.11,362.6h94.05c0,3.63-2.95,6.58-6.58,6.58h-80.89c-3.63,0-6.58-2.95-6.58-6.58h0Z" />
      </svg>
      {/* The screen opening, as percentages of the 650 x 400 drawing. */}
      <div className="absolute left-[11.46%] top-[5.33%] h-[80.96%] w-[77.11%] overflow-hidden rounded-[1%] bg-white [container-type:inline-size]">{children}</div>
      <svg viewBox="0 0 650 400" fill="none" className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
        <path fill="#000" d="M298.14,21.02h54.07v6.5c0,1.56-1.27,2.82-2.82,2.82h-48.42c-1.56,0-2.82-1.27-2.82-2.82v-6.5h0Z" />
        <circle cx="325.1" cy="23.6" r="1.5" fill="#080d4c" />
      </svg>
    </div>
  );
}

/** A phone frame built from rounded boxes, with a camera island. */
function Phone({ children }: { children: ReactNode }) {
  return (
    <div className="relative aspect-[1/2] w-full rounded-[18%/9%] bg-[#303333] p-[4.5%] shadow-[0_30px_40px_-10px_rgba(15,23,42,0.45)] ring-1 ring-black/40">
      <div className="relative h-full w-full overflow-hidden rounded-[15%/7.2%] bg-white ring-[3px] ring-black [container-type:inline-size]">
        {children}
        <span className="absolute left-1/2 top-[2.2%] h-[4.2%] w-[30%] -translate-x-1/2 rounded-full bg-black" aria-hidden="true" />
      </div>
    </div>
  );
}

/**
 * The made-up site shown on both screens. `compact` is the phone layout.
 * Every size inside is in em, and the root font size is a share of the screen's
 * width (cqw), so the whole page shrinks and grows with the device frame.
 */
function Site({ compact = false }: { compact?: boolean }) {
  return (
    <div className={`responsive-showcase-with-laptop-and-phone-device-mockups-scroll text-stone-900 ${compact ? "pt-[2.2em] text-[6.6cqw]" : "text-[2.08cqw]"}`}>
      <div className={`flex items-center justify-between ${compact ? "px-[1.2em] py-[0.8em]" : "px-[2em] py-[1.2em]"}`}>
        <span className={`font-semibold tracking-tight ${compact ? "text-[1em]" : "text-[1.2em]"}`}>Fernhill Pilates</span>
        {compact ? (
          <span className="flex flex-col gap-[0.2em]">
            <span className="h-px w-[1.2em] bg-stone-900" />
            <span className="h-px w-[1.2em] bg-stone-900" />
          </span>
        ) : (
          <span className="flex items-center gap-[1.6em] text-[0.9em] text-stone-600">
            <span>Classes</span>
            <span>Teachers</span>
            <span>Pricing</span>
            <span className="rounded-full bg-stone-900 px-[1.1em] py-[0.45em] text-white">Book</span>
          </span>
        )}
      </div>
      <div className={`bg-gradient-to-br from-rose-100 via-orange-50 to-amber-100 ${compact ? "px-[1.2em] py-[2em]" : "grid grid-cols-[1.2fr_1fr] items-center gap-[1.6em] px-[2em] py-[2.8em]"}`}>
        <div>
          <p className={`font-semibold leading-tight tracking-tight ${compact ? "text-[1.4em]" : "text-[2em]"}`}>Stronger, slower, forty five minutes at a time</p>
          <p className={`mt-[0.7em] text-stone-600 ${compact ? "text-[0.8em]" : "text-[0.9em]"}`}>Small classes above the bakery on Fernhill Road.</p>
          <span className={`mt-[1.2em] inline-block rounded-full bg-rose-600 px-[1.3em] py-[0.55em] font-semibold text-white ${compact ? "text-[0.8em]" : "text-[0.9em]"}`}>See this week</span>
        </div>
        <div className={`rounded-[1.2em] bg-gradient-to-br from-rose-300 to-orange-200 ${compact ? "mt-[1.6em] h-[6.4em]" : "h-[9.6em]"}`}>
          <div className="mx-auto h-full w-2/3 rounded-t-full bg-white/40" />
        </div>
      </div>
      <div className={`grid gap-[0.8em] ${compact ? "grid-cols-1 p-[1.2em]" : "grid-cols-4 p-[2em]"}`}>
        {classes.map((c) => (
          <div key={c.name} className={`rounded-[0.8em] border border-stone-200 ${compact ? "flex items-center gap-[0.8em] p-[0.6em]" : "p-[0.8em]"}`}>
            <div className={`rounded-[0.6em] ${c.tone} ${compact ? "h-[2.8em] w-[2.8em] shrink-0" : "h-[4em]"}`} />
            <div className={compact ? "min-w-0 flex-1" : "mt-[0.6em]"}>
              <p className={`truncate font-semibold ${compact ? "text-[0.8em]" : "text-[0.9em]"}`}>{c.name}</p>
              <p className="text-[0.7em] text-stone-600">
                {c.time} ยท {c.spots}
              </p>
            </div>
          </div>
        ))}
      </div>
      <div className={`grid gap-[0.8em] bg-stone-900 text-white ${compact ? "p-[1.2em]" : "grid-cols-3 p-[2em]"}`}>
        {["First class free", "No joining fee", "Mats provided"].map((t) => (
          <p key={t} className={`font-medium ${compact ? "text-[0.8em]" : "text-[0.9em]"}`}>
            {t}
          </p>
        ))}
      </div>
      <div className={`${compact ? "h-[6.4em]" : "h-[8em]"} bg-stone-100`} />
    </div>
  );
}

export default function ResponsiveShowcaseWithLaptopAndPhoneDeviceMockups() {
  const reduce = useReducedMotion();

  return (
    <section className="overflow-hidden bg-gradient-to-b from-stone-100 to-stone-200 px-4 py-16 sm:px-6 sm:py-24">
      <style>{`
        @keyframes responsive-showcase-with-laptop-and-phone-device-mockups-scroll {
          0%, 18% { transform: translateY(0); }
          50%, 68% { transform: translateY(var(--scroll, -28%)); }
          100% { transform: translateY(0); }
        }
        .responsive-showcase-with-laptop-and-phone-device-mockups-scroll {
          animation: responsive-showcase-with-laptop-and-phone-device-mockups-scroll 9s cubic-bezier(0.65, 0, 0.35, 1) infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .responsive-showcase-with-laptop-and-phone-device-mockups-scroll { animation: none; }
        }
      `}</style>

      <div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[0.8fr_1.2fr]">
        <div>
          <p className="text-sm font-semibold uppercase tracking-widest text-rose-700">One site, every screen</p>
          <h2 className="mt-3 text-4xl font-semibold tracking-tight text-stone-900 sm:text-5xl">Your studio, looking sharp on a laptop and in a pocket</h2>
          <ul className="mt-7 space-y-3">
            {points.map((p) => (
              <li key={p} className="flex items-start gap-3 text-lg text-stone-700">
                <Check className="mt-1 h-5 w-5 shrink-0 text-rose-600" aria-hidden="true" />
                {p}
              </li>
            ))}
          </ul>
          <a href="#" className="mt-9 inline-flex h-12 items-center gap-2 rounded-full bg-stone-900 px-7 text-sm font-semibold text-white transition-colors hover:bg-rose-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-700 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100">
            Start with this layout <ArrowRight className="h-4 w-4" aria-hidden="true" />
          </a>
        </div>

        <div className="relative pb-8 pr-[6%]" role="img" aria-label="A pilates studio website shown on a laptop and on a phone">
          <motion.div
            initial={reduce ? false : { opacity: 0, y: 40 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ type: "spring", stiffness: 70, damping: 16 }}
          >
            <Laptop>
              <Site />
            </Laptop>
          </motion.div>
          <motion.div
            className="absolute bottom-0 right-0 w-[23%]"
            initial={reduce ? false : { opacity: 0, y: 60, rotate: 8 }}
            animate={reduce ? { opacity: 1 } : { opacity: 1, y: [0, -8, 0], rotate: 0 }}
            transition={reduce ? { duration: 0 } : { opacity: { duration: 0.5, delay: 0.25 }, rotate: { type: "spring", stiffness: 80, damping: 14, delay: 0.25 }, y: { duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.9 } }}
            style={{ "--scroll": "-38%" } as CSSProperties}
          >
            <Phone>
              <Site compact />
            </Phone>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/responsive-showcase-with-laptop-and-phone-device-mockups.json

When to use it

How to customize it

Accessibility

Questions

How is the screen content placed inside the laptop?

The laptop is an SVG with a known 650 by 400 drawing. The screen opening sits at fixed coordinates in it, so an absolutely positioned div uses the same numbers as percentages (`left-[11.46%] top-[5.33%]` and so on). Because both scale with the wrapper, they stay aligned at any width.

Can I put a screenshot in the frame?

Yes. Pass an `img` with `className="h-full w-full object-cover object-top"` as the child. Use an image about twice the displayed size so it stays crisp on high density screens.

How does the page inside the screens scale with the frame?

Each screen is a size container, and the demo page sets its font size as a share of that container's width with the `cqw` unit. Every measurement inside is in `em`, so text, padding and corners all shrink and grow with the device.

How do I show only the laptop or only the phone?

Both are separate components. Delete the phone's wrapper for a laptop-only section, or centre a single `Phone` in a narrow column.

Does the self-scrolling page cost much?

No. It is one CSS transform animation per screen, which the browser runs on the compositor without recalculating layout.

Adapted from MacBook Pro in Eldora UI, used under its open-source licence. Licences and credits

Related: App Landing Hero with Phone Mockup, Dark Hero with Gradient Glow and Product Window, Alternating Feature Rows with Illustrations.

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

More in Content

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