# Screenshot Capture

> The iOS screenshot moment — flash, the screen shrinks into a corner thumbnail, lingers, then swipes away.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/screenshot-capture).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/screenshot-capture
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/screenshot-capture-light-2a340f285524d2eb.png
- Registry item: https://uicapsule.com/r/screenshot-capture.json
- Categories: effects, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/screenshot-capture.json
```

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

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `/phone-frame.tsx`

```tsx
import type { ReactNode } from "react";

// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.
export const SCREEN_WIDTH = 330;
export const SCREEN_HEIGHT = 716;

interface Clay {
  base: string;
  deep: string;
  light: string;
  /** RGB triplet for inner shading, tinted so each tone reads as one material. */
  rim: string;
}

// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.
const TONES = {
  espresso: { base: "#3a2b24", deep: "#1f1612", light: "#5a4436", rim: "32 20 14" },
  graphite: { base: "#303036", deep: "#18181c", light: "#4a4a52", rim: "10 10 14" },
  moss: { base: "#2f3a2c", deep: "#171d15", light: "#4a5a44", rim: "12 18 10" },
  ochre: { base: "#4a3c20", deep: "#261e0e", light: "#6e5a30", rim: "30 22 6" },
  plum: { base: "#3c2a3a", deep: "#1e141d", light: "#5c4058", rim: "26 12 24" },
  slate: { base: "#2a3448", deep: "#141a26", light: "#435270", rim: "10 16 30" },
  teal: { base: "#233a3a", deep: "#101d1d", light: "#38585a", rim: "6 22 22" },
  terracotta: { base: "#4a2c22", deep: "#26150f", light: "#6c4232", rim: "34 14 8" },
} satisfies Record<string, Clay>;

export type ClayTone = keyof typeof TONES;

const shellShadow = (rim: string) =>
  [
    "0 60px 100px -40px rgb(0 0 0 / 0.85)",
    "0 26px 40px -24px rgb(0 0 0 / 0.7)",
    "inset 0 6px 6px rgb(255 255 255 / 0.16)",
    `inset 0 -14px 22px rgb(${rim} / 0.75)`,
    "inset 12px 0 18px -12px rgb(255 255 255 / 0.1)",
    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,
  ].join(", ");

const Nub = ({ className, clay }: { className: string; clay: Clay }) => (
  <span
    aria-hidden="true"
    className={`absolute w-[9px] rounded-full ${className}`}
    style={{
      background: clay.base,
      boxShadow: `inset 2px 2px 2px rgb(255 255 255 / 0.14), inset -2px -2px 3px rgb(${clay.rim} / 0.7), 0 2px 4px rgb(0 0 0 / 0.5)`,
    }}
  />
);

interface PhoneFrameProps {
  children: ReactNode;
  /** Classes for the screen: background, text color, pointer handling. */
  className?: string;
  /** Hide the Dynamic Island when the screen draws its own status area. */
  island?: boolean;
  tone?: ClayTone;
}

export const PhoneFrame = ({
  children,
  className,
  island = true,
  tone = "graphite",
}: PhoneFrameProps) => {
  const clay = TONES[tone];
  return (
    <div
      className="relative shrink-0 rounded-[66px] p-[16px]"
      style={{
        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,
        boxShadow: shellShadow(clay.rim),
      }}
    >
      <Nub clay={clay} className="top-[132px] -left-[6px] h-[36px]" />
      <Nub clay={clay} className="top-[188px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[262px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[206px] -right-[6px] h-[92px]" />
      <div
        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? "bg-black"}`}
        style={{
          boxShadow: `0 0 0 2px rgb(${clay.rim} / 0.9), 0 -2px 3px rgb(${clay.rim} / 0.8), 0 3px 4px rgb(255 255 255 / 0.12)`,
          height: SCREEN_HEIGHT,
          width: SCREEN_WIDTH,
        }}
      >
        {children}
        {island && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black"
          />
        )}
      </div>
    </div>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import type { CSSProperties } from "react";

import { ScreenshotCapture } from "./screenshot-capture";

// A low horizon glow under a rose-to-plum sky: dusk, not the flat amber of daylight.
const backdrop: CSSProperties = {
  background: [
    "radial-gradient(ellipse 70% 38% at 50% 100%, rgb(214 120 96 / 0.42), transparent 72%)",
    "radial-gradient(ellipse 60% 50% at 50% 78%, rgb(160 68 88 / 0.35), transparent 75%)",
    "linear-gradient(180deg, #170c17 0%, #2a1224 45%, #4a1f30 80%, #5c2a32 100%)",
  ].join(", "),
};

const Preview = () => (
  <main className="flex h-dvh items-center justify-center overflow-hidden p-5" style={backdrop}>
    <ScreenshotCapture />
  </main>
);

export default Preview;
```

### `/screenshot-capture.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import type { FC } from "react";
import { Camera } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";

import { PhoneFrame, SCREEN_HEIGHT, SCREEN_WIDTH } from "./phone-frame";

const LINGER_MS = 4500;
const THUMB_SCALE = 0.2;
const THUMB_INSET = 17;
const THUMB_BOTTOM = 30;
const thumbX = THUMB_INSET - (SCREEN_WIDTH * (1 - THUMB_SCALE)) / 2;
const thumbY = (SCREEN_HEIGHT * (1 - THUMB_SCALE)) / 2 - THUMB_BOTTOM;

// The "wallpaper" is pure CSS so the thumbnail can be an exact copy.
const Scene: FC = () => (
  <div className="absolute inset-0 overflow-hidden bg-[#1a0e1c]">
    <div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_18%,#fbbf24_0%,#f97316_18%,transparent_40%)]" />
    <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,#8a3350_0%,transparent_60%)]" />
    <div
      className="absolute bottom-0 left-[-30%] h-80 w-[90%] bg-[#3a2036]"
      style={{ clipPath: "polygon(0 100%, 50% 0, 100% 100%)" }}
    />
    <div
      className="absolute bottom-0 right-[-25%] h-64 w-[80%] bg-[#26142a]"
      style={{ clipPath: "polygon(0 100%, 45% 8%, 100% 100%)" }}
    />
    <div className="absolute right-0 bottom-0 left-0 h-32 bg-gradient-to-t from-[#1a0e1c]/80 to-transparent" />
    <p className="absolute top-[76px] left-6 text-[13px] font-medium text-white/60">Golden hour</p>
    <p className="absolute top-[96px] left-6 text-[26px] font-semibold tracking-tight text-white">
      Dolomites, Italy
    </p>
  </div>
);

interface Shot {
  id: number;
}

export const ScreenshotCapture = () => {
  const [flashKey, setFlashKey] = useState(0);
  const [shot, setShot] = useState<Shot | null>(null);
  const nextIdRef = useRef(1);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(
    () => () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    },
    [],
  );

  const capture = useCallback(() => {
    const id = nextIdRef.current;
    nextIdRef.current += 1;
    setFlashKey(id);
    setShot({ id });
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    timerRef.current = setTimeout(() => setShot(null), LINGER_MS);
  }, []);

  return (
    <MotionConfig reducedMotion="user">
      <PhoneFrame className="bg-[#1a0e1c]" tone="espresso">
        <Scene />

        <AnimatePresence>
          {flashKey > 0 && (
            <motion.div
              key={flashKey}
              aria-hidden
              initial={{ opacity: 0.95 }}
              animate={{ opacity: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.45, ease: "easeOut" }}
              className="pointer-events-none absolute inset-0 z-30 bg-white"
            />
          )}
        </AnimatePresence>

        <AnimatePresence>
          {shot && (
            <motion.div
              key={shot.id}
              drag="x"
              dragConstraints={{ left: 0, right: 0 }}
              dragElastic={{ left: 0.9, right: 0.05 }}
              onDragEnd={(_, info) => {
                if (info.offset.x < -40) {
                  setShot(null);
                }
              }}
              initial={{ borderRadius: 50, scale: 1, x: 0, y: 0 }}
              animate={{ borderRadius: 40, scale: THUMB_SCALE, x: thumbX, y: thumbY }}
              exit={{ opacity: 0, transition: { duration: 0.25, ease: "easeIn" }, x: -420 }}
              transition={{ bounce: 0.22, delay: 0.12, duration: 0.65, type: "spring" }}
              className="absolute inset-0 z-20 cursor-grab overflow-hidden shadow-2xl shadow-black/80 ring-[10px] ring-white active:cursor-grabbing"
            >
              <Scene />
            </motion.div>
          )}
        </AnimatePresence>

        <div className="absolute inset-x-0 bottom-10 z-10 flex justify-center">
          <motion.button
            type="button"
            onClick={capture}
            whileTap={{ scale: 0.92 }}
            className="flex items-center gap-2 rounded-full bg-white/12 px-4 py-2.5 text-[13px] font-medium text-white backdrop-blur-xl ring-1 ring-white/20 transition-colors hover:bg-white/20"
          >
            <Camera className="size-4" />
            Screenshot
          </motion.button>
        </div>

        <AnimatePresence>
          {!shot && (
            <motion.p
              key="hint"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="pointer-events-none absolute inset-x-0 bottom-[92px] z-10 text-center text-[11px] text-white/35"
            >
              Capture, then swipe the thumbnail away
            </motion.p>
          )}
        </AnimatePresence>
      </PhoneFrame>
    </MotionConfig>
  );
};
```
