Skip to content
goodtabs UI
BrowseBrowse components

Browser tilt

A browser window that leans back in perspective and lies flat as it scrolls into place.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/browser-tilt.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/browser-tilt

A library page in the frame under a hero heading. Scroll to lay it flat.

Usage

import { BrowserTilt } from "@/components/ui/browser-tilt"

<BrowserTilt url="acme.dev/dashboard">
  <img src="/dashboard.png" alt="The dashboard with three charts" />
</BrowserTilt>

Props

PropTypeDefaultDescription
urlstring"goodtabs.co"Shown in the address field.
tiltnumber24Starting angle in degrees, leaning back from the bottom edge.
childrenReactNodeThe page shown inside the frame.

Source

browser-tilt/browser-tilt.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { LockIcon } from "lucide-react";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const STAGE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";

type BrowserTiltProps = React.ComponentProps<"div"> & {
  /** Shown in the address field. */
  url?: string;
  /** Starting angle in degrees, leaning back from the bottom edge. */
  tilt?: number;
};

/**
 * A browser window that starts leaning back and lies flat as it scrolls into place, with a soft light
 * under it. Flat on phones and under reduced motion, which is also how it renders with no script.
 */
function BrowserTilt({ url = "goodtabs.co", tilt = 24, className, style, children, ...props }: BrowserTiltProps) {
  const root = React.useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(STAGE, () => {
        // clamp() keeps the start on the page, so a frame already in view at load still begins fully tilted.
        gsap
          .timeline({
            defaults: { ease: "none", duration: 1 },
            scrollTrigger: { trigger: root.current, start: "clamp(top 85%)", end: "top 12%", scrub: true },
          })
          .fromTo("[data-slot=browser-tilt-frame]", { rotateX: tilt }, { rotateX: 0 }, 0)
          .fromTo("[data-slot=browser-tilt-light]", { opacity: 0.2 }, { opacity: 1 }, 0);
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [tilt] },
  );

  return (
    <div
      ref={root}
      data-slot="browser-tilt"
      className={cn("relative mx-auto w-full max-w-5xl perspective-[1600px]", className)}
      style={{ "--browser-tilt": `${tilt}deg`, ...style } as React.CSSProperties}
      {...props}
    >
      <div
        aria-hidden="true"
        data-slot="browser-tilt-light"
        className="pointer-events-none absolute inset-x-0 -bottom-48 h-96 bg-[radial-gradient(closest-side,var(--glow),transparent),radial-gradient(closest-side,var(--glow),transparent_70%)] md:-bottom-64 md:h-[32rem]"
      />
      <div
        data-slot="browser-tilt-frame"
        className="relative origin-bottom overflow-hidden rounded-lg border bg-background shadow-lg md:motion-safe:not-noscript:[transform:rotateX(var(--browser-tilt))]"
      >
        <div className="surface-shine grid h-10 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 bg-muted px-3 md:grid-cols-[1fr_minmax(0,2fr)_1fr]">
          <div aria-hidden="true" className="flex items-center gap-1.5">
            <span className="size-2.5 rounded-full bg-muted-foreground/30" />
            <span className="size-2.5 rounded-full bg-muted-foreground/30" />
            <span className="size-2.5 rounded-full bg-muted-foreground/30" />
          </div>
          <div className="flex h-6 min-w-0 items-center justify-center gap-1.5 rounded-sm bg-background px-2.5 font-mono text-xs text-foreground">
            <LockIcon className="size-3 shrink-0" aria-hidden="true" />
            <span className="truncate">{url}</span>
          </div>
        </div>
        <div className="border-t bg-background">{children}</div>
      </div>
    </div>
  );
}

export { BrowserTilt };

For AI agents

Hand your agent this page, the markdown at /components/browser-tilt/prompt.md, or the Copy AI prompt button above.

When to use

  • The product shot under a hero heading
  • One big reveal of a screen on a landing page
  • A page that needs a single moment of depth and nothing else

When not to use

  • For small previews or lists of screenshots: use the plain browser frame
  • More than once on a page, because the second tilt repeats the trick
  • Inside a scaled or transformed parent, which breaks ScrollTrigger's measuring

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • Keep scroll room below it: the frame only lies flat when its top reaches 12 percent of the viewport
  • Below 768px, under reduced motion and with no script it renders flat. Under motion with script on, CSS paints the tilt before hydration, so a hero frame never jumps
  • The light reads the --glow token and the top edge uses surface-shine; define both or the frame simply renders without them

The maths

  • The top edge shrinks by p / (p + H sin θ). At 1600px perspective, 620px tall and 24 degrees that is 0.86, so it reads 14 percent narrower.
  • Height on screen is H cos θ, so the frame opens from 91 percent of its height to all of it as θ falls to 0.
  • The light scrubs opacity from 0.2 to 1 against the same progress as the angle, so it brightens as the frame flattens.