{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "laptop-frame",
  "title": "Laptop Frame",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A notched aluminium laptop drawn in CSS that scales with its container. Renders children, an image or a video as the screen, and can open its lid when it scrolls into view.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/media.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/laptop-frame.tsx",
      "content": "// Ballmac UI: Laptop Frame. https://ui.ballmac.com/components/laptop-frame\n\"use client\"\n\nimport * as React from \"react\"\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useScroll, useSpring, useTransform } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Media } from \"@/components/ballmac/media\"\n\ntype LaptopFrameProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Screen content. Ignored when `src` or `videoSrc` is set. */\n  children?: React.ReactNode\n  /** Finish of the aluminium. \"auto\" is silver in light mode and midnight in dark mode. */\n  variant?: \"auto\" | \"silver\" | \"midnight\"\n  /** Image shown on the screen, cropped to fill it. */\n  src?: string\n  /** Alternative text for `src`. */\n  alt?: string\n  /** Video shown on the screen: muted, looping, and paused under reduced motion. */\n  videoSrc?: string\n  /** Poster image for `videoSrc`. */\n  poster?: string\n  /**\n   * Width in CSS pixels that the screen content is laid out at; it is then scaled to fit the screen,\n   * like a real display resolution. Omit to lay content out at the frame's actual size.\n   */\n  screenWidth?: number\n  /**\n   * Opens the lid: \"in-view\" plays once when the laptop scrolls into view, \"scroll\" ties the hinge to\n   * scroll position. Reduced motion always shows the laptop open.\n   */\n  openAnimation?: \"none\" | \"in-view\" | \"scroll\"\n  /** Class names for the screen element (the area inside the bezel). */\n  screenClassName?: string\n}\n\n// Aluminium palettes as CSS variables, so \"auto\" can switch with the theme in plain CSS.\nconst SILVER =\n  \"[--lf-shell-hi:color-mix(in_oklch,white_97%,black)] [--lf-shell:color-mix(in_oklch,white_86%,black)] [--lf-shell-lo:color-mix(in_oklch,white_70%,black)] [--lf-edge:color-mix(in_oklch,white_60%,black)] [--lf-scoop:color-mix(in_oklch,white_74%,black)]\"\nconst MIDNIGHT =\n  \"[--lf-shell-hi:color-mix(in_oklch,white_52%,black)] [--lf-shell:color-mix(in_oklch,white_30%,black)] [--lf-shell-lo:color-mix(in_oklch,white_16%,black)] [--lf-edge:color-mix(in_oklch,white_30%,black)] [--lf-scoop:color-mix(in_oklch,white_14%,black)]\"\nconst MIDNIGHT_DARK =\n  \"dark:[--lf-shell-hi:color-mix(in_oklch,white_52%,black)] dark:[--lf-shell:color-mix(in_oklch,white_30%,black)] dark:[--lf-shell-lo:color-mix(in_oklch,white_16%,black)] dark:[--lf-edge:color-mix(in_oklch,white_30%,black)] dark:[--lf-scoop:color-mix(in_oklch,white_14%,black)]\"\n\nconst CLOSED_ANGLE = -90\n\n/** Lays screen content out at `screenWidth` CSS pixels and scales it to fit, without re-rendering. */\nfunction useScreenScale(screenWidth: number | undefined) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  React.useEffect(() => {\n    const screen = ref.current\n    if (!screen || !screenWidth) return\n    const measure = () => screen.style.setProperty(\"--screen-scale\", String(screen.clientWidth / screenWidth))\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(screen)\n    return () => observer.disconnect()\n  }, [screenWidth])\n  return ref\n}\n\nfunction ScreenContent({\n  children,\n  src,\n  alt = \"\",\n  videoSrc,\n  poster,\n  screenWidth,\n  reduceMotion,\n}: Pick<LaptopFrameProps, \"children\" | \"src\" | \"alt\" | \"videoSrc\" | \"poster\" | \"screenWidth\"> & { reduceMotion: boolean }) {\n  if (videoSrc) {\n    return (\n      <video\n        data-slot=\"laptop-frame-video\"\n        className=\"size-full object-cover\"\n        src={videoSrc}\n        poster={poster}\n        autoPlay={!reduceMotion}\n        muted\n        loop\n        playsInline\n      />\n    )\n  }\n  if (src) {\n    // eslint-disable-next-line @next/next/no-img-element\n    return <Media data-slot=\"laptop-frame-image\" media={src} alt={alt} fill className=\"size-full\" />\n  }\n  if (!screenWidth) return <>{children}</>\n  return (\n    <div\n      data-slot=\"laptop-frame-viewport\"\n      className=\"absolute top-0 left-0 origin-top-left\"\n      style={{\n        width: screenWidth,\n        height: \"calc(100% / var(--screen-scale, 1))\",\n        transform: \"scale(var(--screen-scale, 1))\",\n      }}\n    >\n      {children}\n    </div>\n  )\n}\n\nfunction LaptopFrame({\n  variant = \"auto\",\n  src,\n  alt,\n  videoSrc,\n  poster,\n  screenWidth,\n  openAnimation = \"none\",\n  screenClassName,\n  className,\n  children,\n  ...props\n}: LaptopFrameProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const screenRef = useScreenScale(src || videoSrc ? undefined : screenWidth)\n  const reduceMotion = !!useReducedMotion()\n  const inView = useInView(rootRef, { once: true, amount: 0.35 })\n\n  // Hinge angle in degrees: 0 is open, -90 is closed (lid flat on the base).\n  const angle = useMotionValue(openAnimation === \"none\" ? 0 : CLOSED_ANGLE)\n  const { scrollYProgress } = useScroll({ target: rootRef, offset: [\"start end\", \"center 55%\"] })\n  const scrollAngle = useSpring(useTransform(scrollYProgress, [0, 1], [CLOSED_ANGLE, 0]), {\n    stiffness: 140,\n    damping: 26,\n  })\n\n  React.useEffect(() => {\n    if (openAnimation === \"none\" || reduceMotion) {\n      angle.set(0)\n      return\n    }\n    if (openAnimation === \"scroll\") {\n      angle.set(scrollAngle.get())\n      return scrollAngle.on(\"change\", (v) => angle.set(v))\n    }\n    if (!inView) {\n      angle.set(CLOSED_ANGLE)\n      return\n    }\n    const controls = animate(angle, 0, { type: \"spring\", stiffness: 60, damping: 16, mass: 1.1, delay: 0.15 })\n    return () => controls.stop()\n  }, [openAnimation, reduceMotion, inView, angle, scrollAngle])\n\n  // The screen dims as the lid closes, like a display facing away from the light.\n  const glare = useTransform(angle, [CLOSED_ANGLE, -30, 0], [0.9, 0.35, 0])\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"laptop-frame\"\n      data-variant={variant}\n      className={cn(\n        \"@container relative w-full select-none\",\n        variant === \"silver\" && SILVER,\n        variant === \"midnight\" && MIDNIGHT,\n        variant === \"auto\" && cn(SILVER, MIDNIGHT_DARK),\n        className\n      )}\n      {...props}\n    >\n      {/* Lid */}\n      <div className=\"relative mx-auto w-[82%] perspective-[240cqw] perspective-origin-[50%_20%]\">\n        <motion.div\n          data-slot=\"laptop-frame-lid\"\n          className=\"relative origin-bottom transform-3d\"\n          style={{ rotateX: angle }}\n        >\n          {/* Front: aluminium edge, black bezel, screen. */}\n          <div className=\"relative rounded-t-[2.6cqw] rounded-b-[0.5cqw] bg-(--lf-edge) p-[0.28cqw] backface-hidden shadow-[0_0.6cqw_2cqw_-0.6cqw_rgb(0_0_0/0.35)]\">\n            <div className=\"relative rounded-t-[2.35cqw] rounded-b-[0.35cqw] bg-black p-[1.05cqw] pb-[1.3cqw] shadow-[inset_0_0_0_0.12cqw_rgb(255_255_255/0.08)]\">\n              <div\n                ref={screenRef}\n                data-slot=\"laptop-frame-screen\"\n                className={cn(\n                  \"relative isolate aspect-[16/10.3] overflow-hidden rounded-t-[1.35cqw] rounded-b-[0.2cqw] bg-background text-foreground\",\n                  screenClassName\n                )}\n              >\n                <ScreenContent\n                  src={src}\n                  alt={alt}\n                  videoSrc={videoSrc}\n                  poster={poster}\n                  screenWidth={screenWidth}\n                  reduceMotion={reduceMotion}\n                >\n                  {children}\n                </ScreenContent>\n                {/* Glass: a soft diagonal reflection plus dimming while closed. */}\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-0 z-10 bg-[linear-gradient(115deg,rgb(255_255_255/0.07)_0%,rgb(255_255_255/0.02)_38%,transparent_38.2%)]\"\n                />\n                <motion.span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-0 z-10 bg-black\"\n                  style={{ opacity: glare }}\n                />\n              </div>\n              {/* Notch with camera. */}\n              <span\n                aria-hidden=\"true\"\n                data-slot=\"laptop-frame-notch\"\n                className=\"pointer-events-none absolute top-0 left-1/2 z-20 flex h-[2.15cqw] w-[11%] -translate-x-1/2 items-center justify-center rounded-b-[0.75cqw] bg-black\"\n              >\n                <span className=\"size-[0.55cqw] rounded-full bg-[radial-gradient(circle_at_35%_35%,rgb(120_140_255/0.55),rgb(255_255_255/0.06)_45%,transparent_70%)] ring-[0.08cqw] ring-white/10\" />\n              </span>\n            </div>\n          </div>\n          {/* Back of the lid, seen while it is closing. */}\n          <div\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 rotate-x-180 rounded-t-[0.5cqw] rounded-b-[2.6cqw] bg-[linear-gradient(180deg,var(--lf-shell-lo),var(--lf-shell)_55%,var(--lf-shell-hi))] backface-hidden\"\n          />\n        </motion.div>\n      </div>\n\n      {/* Hinge */}\n      <div\n        aria-hidden=\"true\"\n        className=\"relative mx-auto h-[0.75cqw] w-[70%] rounded-b-[0.6cqw] bg-[linear-gradient(180deg,rgb(0_0_0/0.85),color-mix(in_oklch,var(--lf-shell-lo)_70%,black))]\"\n      />\n\n      {/* Base */}\n      <div aria-hidden=\"true\" data-slot=\"laptop-frame-base\" className=\"relative -mt-[0.1cqw] h-[2.1cqw] w-full\">\n        <div className=\"absolute inset-0 rounded-t-[0.4cqw] rounded-b-[4cqw_100%] bg-[linear-gradient(180deg,var(--lf-shell-hi)_0%,var(--lf-shell)_28%,var(--lf-shell-lo)_100%)] shadow-[inset_0_0.1cqw_0_rgb(255_255_255/0.6),inset_0_-0.15cqw_0.2cqw_rgb(0_0_0/0.18)]\" />\n        {/* Thumb scoop */}\n        <div className=\"absolute top-0 left-1/2 h-[48%] w-[15%] -translate-x-1/2 rounded-b-[1cqw] bg-[linear-gradient(180deg,var(--lf-scoop),var(--lf-shell))] shadow-[inset_0_0.12cqw_0.25cqw_rgb(0_0_0/0.25)]\" />\n        {/* Contact shadow */}\n        <div className=\"absolute inset-x-[4%] -bottom-[1.2cqw] -z-10 h-[1.6cqw] rounded-[100%] bg-black/35 blur-[1cqw] dark:bg-black/70\" />\n      </div>\n    </div>\n  )\n}\n\nexport { LaptopFrame, type LaptopFrameProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/laptop-frame.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "mockup",
      "device",
      "laptop",
      "macbook",
      "screenshot",
      "hero",
      "3d",
      "scroll"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Wrap a product screen in <LaptopFrame>: children become the display. Pass screenWidth (e.g. 1280) to lay the content out at a desktop resolution and scale it down to fit; openAnimation=\"in-view\" or \"scroll\" swings the lid open.",
      "whenToUse": [
        "Landing-page heroes that show a desktop or web app in context",
        "Feature sections with a live React screen instead of a static screenshot",
        "Scroll-driven product reveals (openAnimation=\"scroll\")"
      ],
      "whenNotToUse": [
        "Showing a website with its URL and browser chrome (use browser-frame)",
        "Mobile app screens (use phone-frame)"
      ],
      "composesWith": [
        "browser-frame",
        "phone-frame"
      ],
      "a11y": [],
      "customization": [
        "variant: auto (silver in light, midnight in dark) | silver | midnight",
        "screenWidth: virtual resolution in CSS px; content is scaled with a CSS transform",
        "src/alt for an image, videoSrc/poster for a muted looping video",
        "openAnimation: none | in-view | scroll; reduced motion always shows it open",
        "Sizes are in container units, so set the width with className (e.g. max-w-3xl)"
      ]
    },
    "examples": [
      "laptop-frame-demo",
      "laptop-frame-scroll"
    ],
    "url": "https://ui.ballmac.com/components/laptop-frame"
  },
  "categories": [
    "devices"
  ],
  "type": "registry:ui"
}