# Intro Loader: Studio

> A studio's home page. Four projects flip past in the frame, then the fifth grows into the full-bleed hero, and the headline rises over it. Press reload in the address bar to play it again, or click to skip.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/intro-loader-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/intro-loader-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/intro-loader-demo-light-0601e7200249ee43.png
- Registry item: https://keyframeui.com/r/intro-loader-demo.json
- Categories: preloader, hero, image
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/intro-loader-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/intro-loader.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/intro-loader#intro-loader-demo

## Files

### `registry/demos/intro-loader-demo.tsx`

```tsx
"use client";

import * as React from "react";

import {
  IntroLoader,
  IntroLoaderLine,
  IntroLoaderTarget,
  useIntroDone,
} from "@/components/ui/intro-loader";

const image = (id: string, w: number, h: number) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;

// The last image is the hero, at the same address as the one on the page, so
// it downloads once and lands on itself.
const hero = image("1600585154340-be6161a56a0c", 1800, 1100);
const work = [
  image("1561070791-2526d30994b5", 480, 600),
  image("1487958449943-2429e8be8625", 480, 600),
  image("1585386959984-a4155224a1ad", 480, 600),
  image("1505740420928-5e560c06d30e", 480, 600),
  hero,
];

// The reload button plays it again by remounting it with a new key. On a real
// site, drop `contained` and `once={false}`: it covers the screen, once per
// visit.
export function IntroLoaderDemo() {
  const [run, setRun] = React.useState(0);

  return (
    <div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-zinc-200 bg-[#f4f3ef] text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <BrowserBar url="nord.studio" run={run} onReload={() => setRun((r) => r + 1)} />
      <IntroLoader
        key={run}
        contained
        once={false}
        images={work}
        // Set like the logo in the header, so the page's logo takes its place.
        brand={<span className="text-sm leading-5 font-semibold tracking-tight">Nord Studio</span>}
        className="@container flex min-h-0 flex-1 flex-col"
      >
        <header className="flex items-start justify-between gap-6 px-5 pt-5 text-sm leading-5 sm:px-8 sm:pt-8">
          <span className="font-semibold tracking-tight">Nord Studio</span>
          <nav className="flex gap-5 sm:gap-7">
            {["Work", "About", "Contact"].map((item) => (
              <IntroLoaderLine key={item} from="above">
                <a
                  href={`#${item.toLowerCase()}`}
                  className="text-zinc-600 transition-colors duration-150 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
                >
                  {item}
                </a>
              </IntroLoaderLine>
            ))}
          </nav>
        </header>

        <section className="relative mt-5 min-h-0 flex-1 overflow-hidden sm:mt-8">
          <IntroLoaderTarget>
            <img
              src={hero}
              alt="A dark timber house lit from inside at dusk, behind a eucalyptus tree"
              className="absolute inset-0 size-full object-cover"
            />
          </IntroLoaderTarget>
          <Scrim />
          <div className="relative flex h-full flex-col justify-end p-5 text-white sm:p-8">
            <h1 className="text-[clamp(2.25rem,7cqw,5.5rem)] leading-[0.95] font-semibold tracking-[-0.045em]">
              <IntroLoaderLine>We design things</IntroLoaderLine>
              <IntroLoaderLine>people remember</IntroLoaderLine>
            </h1>
            <div className="mt-4 flex items-end justify-between gap-6 text-sm text-white/75 sm:mt-6">
              <IntroLoaderLine className="max-w-xs text-pretty">
                Identity, products and spaces for companies that care how things feel.
              </IntroLoaderLine>
              <IntroLoaderLine className="hidden shrink-0 tabular-nums @xl:block">
                Halden House, 2026
              </IntroLoaderLine>
            </div>
          </div>
        </section>
      </IntroLoader>
    </div>
  );
}

/**
 * Darkens the bottom of the hero for the headline. It isn't part of the image
 * the loader lands, so it waits for the handoff and fades in after it.
 */
function Scrim() {
  const done = useIntroDone();
  return (
    <div
      aria-hidden
      className={`absolute inset-0 bg-linear-to-t from-black/55 via-black/10 to-transparent to-60% transition-opacity duration-700 ease-out ${done ? "opacity-100" : "opacity-0"}`}
    />
  );
}

function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
  return (
    <div className="flex h-10 shrink-0 items-center gap-3 border-b border-zinc-200 bg-white/60 px-3 dark:border-zinc-800 dark:bg-zinc-900/60">
      <div aria-hidden className="flex gap-1.5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
        ))}
      </div>
      <button
        type="button"
        onClick={onReload}
        aria-label="Reload to play it again"
        title="Play again"
        className="grid size-7 place-items-center rounded-md text-zinc-500 transition-[background-color,color,scale] duration-150 hover:bg-zinc-200/70 hover:text-zinc-900 active:scale-[0.94] dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
      >
        <svg
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
          style={{ rotate: `${run * 360}deg` }}
        >
          <path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
          <path d="M13.5 2.5v3h-3" />
        </svg>
      </button>
      <span className="mx-auto truncate rounded-md bg-zinc-100 px-3 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-800/80 dark:text-zinc-400">
        {url}
      </span>
      <span aria-hidden className="w-[4.75rem]" />
    </div>
  );
}
```
