# Device Frame

> Browser window and phone frames for screenshots or live UI, with a screen rim that catches the neon.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/device-frame

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/device-frame.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/device-frame`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import { DeviceFrame } from "@/components/ui/device-frame"

<DeviceFrame url="relay.dev/acme/pipelines">
  <img src="/dashboard.png" alt="Pipelines dashboard" className="size-full object-cover" />
</DeviceFrame>
```

## Examples

### Browser

A live page, sized in cqw so it scales with the frame like a screenshot.

```tsx
import { DeviceFrame } from "@/components/ui/device-frame";

// Sized in cqw (a share of the screen's width), so the page scales like a screenshot at any frame size.
const hours = [38, 42, 35, 30, 27, 24, 26, 33, 48, 61, 72, 78, 81, 77, 74, 79, 86, 91, 84, 70, 62, 55, 49, 44];
const pipelines = [
  { name: "billing-events", route: "Stripe → Snowflake", rate: "412/min", ok: true },
  { name: "repo-activity", route: "GitHub → Postgres", rate: "268/min", ok: true },
  { name: "support-sync", route: "Linear → Slack", rate: "51/min", ok: true },
  { name: "lead-routing", route: "Webflow → HubSpot", rate: "12/min", ok: true },
  { name: "error-digest", route: "Sentry → Slack", rate: "paused", ok: false },
];

export default function DeviceFrameDemo() {
  return (
    <DeviceFrame url="relay.dev/acme/pipelines" className="max-w-2xl">
      <div className="flex h-full text-[1.75cqw] text-white/80">
        <aside className="w-[17cqw] shrink-0 border-r border-white/[0.06] p-[2.4cqw]">
          <p className="font-medium text-white/90">acme</p>
          <ul className="mt-[3cqw] space-y-[1.4cqw] text-white/50">
            <li className="-mx-[1cqw] rounded-[0.8cqw] bg-white/[0.05] px-[1cqw] py-[0.5cqw] text-white/90">Pipelines</li>
            <li>Endpoints</li>
            <li>Deliveries</li>
            <li>Replays</li>
            <li>Settings</li>
          </ul>
        </aside>
        <main className="min-w-0 flex-1 p-[2.8cqw]">
          <div className="flex items-baseline justify-between">
            <p className="text-[2.6cqw] font-semibold tracking-tight text-white/90">Pipelines</p>
            <p className="text-white/45">Last 24 h</p>
          </div>
          <dl className="mt-[2.4cqw] grid grid-cols-3 gap-[2cqw]">
            {[
              ["Events", "1.84M"],
              ["Delivered", "99.96%"],
              ["p95 latency", "212 ms"],
            ].map(([label, value]) => (
              <div key={label}>
                <dt className="text-white/45">{label}</dt>
                <dd className="mt-[0.4cqw] font-mono text-[2.6cqw] tabular-nums text-white/90">{value}</dd>
              </div>
            ))}
          </dl>
          <div aria-hidden className="mt-[2.6cqw] flex h-[10cqw] items-end gap-[0.5cqw] border-b border-white/[0.06]">
            {hours.map((h, i) => (
              <span
                key={i}
                style={{ height: `${h}%` }}
                className={
                  i === hours.length - 1
                    ? "flex-1 rounded-t-[0.4cqw] bg-[rgb(var(--neon))] shadow-[0_0_10px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]"
                    : "flex-1 rounded-t-[0.4cqw] bg-[rgb(var(--neon)/0.28)]"
                }
              />
            ))}
          </div>
          <ul className="mt-[2.2cqw] divide-y divide-white/[0.05]">
            {pipelines.map((p) => (
              <li key={p.name} className="flex items-center gap-[1.6cqw] py-[1.1cqw]">
                <span
                  className={
                    p.ok
                      ? "size-[1cqw] shrink-0 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))]"
                      : "size-[1cqw] shrink-0 rounded-full bg-white/20"
                  }
                />
                <span className="w-[22cqw] truncate font-mono text-white/85">{p.name}</span>
                <span className="flex-1 truncate text-white/45">{p.route}</span>
                <span className="font-mono tabular-nums text-white/60">{p.rate}</span>
              </li>
            ))}
          </ul>
        </main>
      </div>
    </DeviceFrame>
  );
}
```

### Phone

Dynamic island, status bar, side buttons. The switch inside works.

```tsx
"use client";

import * as React from "react";
import { DeviceFrame } from "@/components/ui/device-frame";
import { Switch } from "@/components/ui/switch";

export default function DeviceFramePhone() {
  const [paused, setPaused] = React.useState(false);

  return (
    <DeviceFrame variant="phone" time="9:41" className="w-[260px]">
      <div className="flex h-full flex-col px-[7cqw] pb-[10cqw] pt-[17cqw] text-[4.4cqw]">
        <p className="text-white/50">Relay · acme</p>
        <p className="mt-[1.5cqw] text-[8cqw] font-semibold leading-[1.05] tracking-tight text-white/90">
          Stripe deliveries failing
        </p>
        <p className="mt-[3cqw] leading-snug text-white/55">
          api.acme.dev/hooks returned 503 for 214 of the last 230 attempts.
        </p>
        <dl className="mt-[6cqw] grid grid-cols-2 gap-[4cqw]">
          <div>
            <dt className="text-white/45">Queued</dt>
            <dd className="font-mono text-[6.4cqw] tabular-nums text-white/90">1,906</dd>
          </div>
          <div>
            <dt className="text-white/45">Oldest</dt>
            <dd className="font-mono text-[6.4cqw] tabular-nums text-white/90">11 min</dd>
          </div>
        </dl>
        <ul className="mt-[6cqw] divide-y divide-white/[0.06] border-y border-white/[0.06] font-mono text-[3.9cqw]">
          {[
            ["09:41:02", "503", "1,204 ms"],
            ["09:40:31", "503", "1,198 ms"],
            ["09:39:58", "200", "88 ms"],
            ["09:39:20", "503", "1,211 ms"],
          ].map(([at, code, took]) => (
            <li key={at} className="flex justify-between py-[2.4cqw]">
              <span className="text-white/45">{at}</span>
              <span className={code === "200" ? "text-white/60" : "text-[rgb(var(--neon))]"}>{code}</span>
              <span className="w-[20cqw] text-right text-white/60">{took}</span>
            </li>
          ))}
        </ul>
        <div className="mt-auto flex items-center justify-between rounded-[4cqw] border border-white/10 bg-white/[0.03] px-[4.5cqw] py-[3.5cqw]">
          <span className="text-white/85">{paused ? "Retries paused" : "Pause retries"}</span>
          <Switch size="sm" checked={paused} onCheckedChange={setPaused} aria-label="Pause retries" />
        </div>
      </div>
    </DeviceFrame>
  );
}
```

### Tabs and minimal controls

```tsx
import { DeviceFrame } from "@/components/ui/device-frame";

export default function DeviceFrameTabs() {
  return (
    <DeviceFrame
      controls="minimal"
      tabs={["Relay: webhooks that arrive", "Pricing · Relay", "RFC 0042"]}
      url="relay.dev"
      aspectRatio="16 / 9"
      className="max-w-2xl"
    >
      <div className="grid h-full place-items-center px-[8cqw] text-center">
        <div>
          <p className="text-[5.4cqw] font-semibold leading-[1.02] tracking-[-0.035em] text-white/90">
            Webhooks that arrive.
          </p>
          <p className="mx-auto mt-[2cqw] max-w-[52cqw] text-[1.9cqw] leading-relaxed text-white/55">
            At-least-once delivery, replays from the last 30 days and a dashboard your support team can read.
          </p>
          <div className="mt-[3.4cqw] flex justify-center gap-[1.4cqw] text-[1.7cqw] font-medium">
            <span className="rounded-[0.9cqw] bg-[rgb(var(--neon))] px-[2.4cqw] py-[1.1cqw] text-[#05060a] shadow-[0_0_18px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]">
              Start free
            </span>
            <span className="rounded-[0.9cqw] border border-white/15 px-[2.4cqw] py-[1.1cqw] text-white/80">Read the docs</span>
          </div>
        </div>
      </div>
    </DeviceFrame>
  );
}
```

### Tilted pair

tilt turns each device in perspective; overlap them for a hero shot.

```tsx
import { DeviceFrame } from "@/components/ui/device-frame";

const rows = [
  ["evt_8Kq2", "invoice.paid", "200", "84 ms"],
  ["evt_8Kq1", "customer.updated", "200", "61 ms"],
  ["evt_8Kpz", "invoice.paid", "503", "retry 2"],
  ["evt_8Kpy", "charge.refunded", "200", "92 ms"],
  ["evt_8Kpx", "invoice.created", "200", "70 ms"],
];

export default function DeviceFrameTilt() {
  return (
    <div className="relative w-full max-w-2xl pb-[22%]">
      <DeviceFrame url="relay.dev/acme/deliveries" tilt={8} className="w-[86%]">
        <div className="h-full p-[3cqw] text-[1.8cqw]">
          <p className="text-[2.6cqw] font-semibold tracking-tight text-white/90">Deliveries</p>
          <ul className="mt-[2.4cqw] divide-y divide-white/[0.05] font-mono">
            {rows.map(([id, type, code, time]) => (
              <li key={id} className="flex gap-[2cqw] py-[1.3cqw]">
                <span className="w-[13cqw] text-white/45">{id}</span>
                <span className="flex-1 text-white/80">{type}</span>
                <span className={code === "200" ? "text-white/60" : "text-[rgb(var(--neon))]"}>{code}</span>
                <span className="w-[11cqw] text-right text-white/45">{time}</span>
              </li>
            ))}
          </ul>
        </div>
      </DeviceFrame>
      <DeviceFrame variant="phone" time="9:41" tilt={8} className="absolute bottom-0 right-0 w-[27%] min-w-[110px]">
        <div className="flex h-full flex-col px-[7cqw] pb-[12cqw] pt-[17cqw] text-[4.6cqw]">
          <p className="text-white/50">evt_8Kpz · retry 2 of 9</p>
          <p className="mt-[1cqw] text-[7.5cqw] font-semibold leading-tight tracking-tight text-white/90">503 from api.acme.dev</p>
          <ul className="mt-[5cqw] divide-y divide-white/[0.06] border-y border-white/[0.06] font-mono text-[4.2cqw]">
            {[
              ["14:02:13", "503"],
              ["14:02:23", "503"],
              ["14:02:41", "503"],
            ].map(([at, code]) => (
              <li key={at} className="flex justify-between py-[2.4cqw]">
                <span className="text-white/45">{at}</span>
                <span className="text-[rgb(var(--neon))]">{code}</span>
              </li>
            ))}
          </ul>
          <p className="mt-[4cqw] text-white/50">Next attempt in 2 min 30 s</p>
          <p className="mt-auto rounded-[3cqw] bg-[rgb(var(--neon))] py-[3cqw] text-center font-medium text-[#05060a]">Replay now</p>
        </div>
      </DeviceFrame>
    </div>
  );
}
```

## API reference

### DeviceFrame

Children render inside the screen. Also accepts div attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"browser" \| "phone"` | `"browser"` | A browser window or a phone. |
| `url` | `string` | — | Browser: the address, without the scheme. The host is brighter than the path. |
| `tabs` | `string[]` | — | Browser: tab titles. Adds a tab strip and a toolbar. |
| `activeTab` | `number` | `0` | Browser: the tab in front. |
| `controls` | `"traffic" \| "minimal"` | `"traffic"` | Browser: colored window buttons, or grey ones. |
| `cutout` | `"island" \| "notch" \| "none"` | `"island"` | Phone: the cut-out at the top of the screen. |
| `time` | `string` | — | Phone: shows a status bar with this time, e.g. "9:41". |
| `tilt` | `boolean \| number` | — | Turn it in perspective. true is 10°; negative turns the other way. |
| `aspectRatio` | `string` | `"16 / 10" or "9 / 19.5"` | Aspect ratio of the screen. |
| `rim` | `boolean` | `true` | The hairline of --neon where the screen glass meets the frame. |
| `screenClassName` | `string` | — | Class names for the screen. |
| `color` | `NeonColor` | `inherited` | Color the rim reflects. |

## Accessibility

- The frame is decoration: window buttons, tabs, the URL bar, the island and the side buttons are aria-hidden or plain text. Whatever you put on the screen keeps its own semantics, so give screenshots alt text.
- The screen is a size container. Size the content in cqw and it scales with the frame like a screenshot; size it in px and it reflows like a real viewport.
- The phone is sized in cqw of its own width, so one width class scales the bezel, island and buttons together.
- The rim is light caught on the glass edge, not a glow: a 1px tint of --neon with a brighter line along the top. Nothing spills outward.

## Source

`components/ui/device-frame.tsx`

```tsx
"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, Lock, RotateCw, Wifi } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// The screen is glass: where it meets the frame it catches the room's light, so it gets a hairline of
// --neon round the edge, brightest along the top where a lit sign would hit it. Nothing glows outward.
const rim =
  "pointer-events-none absolute inset-0 z-30 rounded-[inherit] shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.22),inset_0_1px_0_rgb(var(--neon)/0.35)]";
const sheen =
  "pointer-events-none absolute inset-x-[8%] top-0 z-30 h-px bg-[linear-gradient(90deg,transparent,rgb(var(--neon)/0.9)_35%,#fff_50%,rgb(var(--neon)/0.9)_65%,transparent)] opacity-70";

function tiltTransform(tilt: boolean | number | undefined) {
  if (!tilt) return undefined;
  const deg = tilt === true ? 10 : tilt;
  return `perspective(2000px) rotateX(${Math.abs(deg) * 0.45}deg) rotateY(${-deg}deg)`;
}

export interface DeviceFrameProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color for the screen rim. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** A browser window or a phone. */
  variant?: "browser" | "phone";
  /** Browser: the address shown in the URL bar, without the scheme. */
  url?: string;
  /** Browser: tab titles. With tabs the window gets a tab strip and a toolbar. */
  tabs?: string[];
  /** Browser: which tab is in front. */
  activeTab?: number;
  /** Browser: colored window buttons, or plain grey ones. */
  controls?: "traffic" | "minimal";
  /** Phone: the cut-out at the top of the screen. */
  cutout?: "island" | "notch" | "none";
  /** Phone: a status bar over the screen showing this time, e.g. "9:41". */
  time?: string;
  /** Turn the device in perspective. `true` is 10°; negative numbers turn it the other way. */
  tilt?: boolean | number;
  /** Aspect ratio of the screen, e.g. `"16 / 10"`. Defaults to 16 / 10 for the browser and 9 / 19.5 for the phone. */
  aspectRatio?: string;
  /** Light the screen's rim with `--neon`. */
  rim?: boolean;
  /** Class names for the screen, where the children render. */
  screenClassName?: string;
}

const dots = {
  traffic: ["bg-[#ff5f57]", "bg-[#febc2e]", "bg-[#28c840]"],
  minimal: ["bg-white/15", "bg-white/15", "bg-white/15"],
} as const;

function UrlBar({ url, wide }: { url: string; wide?: boolean }) {
  const slash = url.indexOf("/");
  const host = slash < 0 ? url : url.slice(0, slash);
  const path = slash < 0 ? "" : url.slice(slash);
  return (
    <div
      className={cn(
        "flex h-7 min-w-0 items-center gap-1.5 rounded-md border border-white/[0.06] bg-white/[0.04] px-2.5 text-[12px]",
        wide ? "flex-1" : "w-full max-w-[26rem] justify-center",
      )}
    >
      <Lock aria-hidden className="size-3 shrink-0 text-white/40" />
      <span className="truncate">
        <span className="text-white/80">{host}</span>
        <span className="text-white/40">{path}</span>
      </span>
    </div>
  );
}

const DeviceFrame = React.forwardRef<HTMLDivElement, DeviceFrameProps>(
  (
    {
      className,
      style,
      color,
      variant = "browser",
      url,
      tabs,
      activeTab = 0,
      controls = "traffic",
      cutout = "island",
      time,
      tilt,
      aspectRatio,
      rim: lit = true,
      screenClassName,
      children,
      ...props
    },
    ref,
  ) => {
    const transform = tiltTransform(tilt);
    const rootStyle = neonVars(color, transform ? { transform, ...style } : style);

    if (variant === "phone") {
      return (
        <div
          ref={ref}
          style={rootStyle}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "relative w-[280px] max-w-full shrink-0 [container-type:inline-size]",
            className,
          )}
          {...props}
        >
          {/* Side buttons: action and volume on the left, power on the right. */}
          <span aria-hidden className="absolute -left-[3px] top-[19%] h-[5%] w-[3px] rounded-l-[2px] bg-[linear-gradient(90deg,#2a2e39,#151820)]" />
          <span aria-hidden className="absolute -left-[3px] top-[27%] h-[9%] w-[3px] rounded-l-[2px] bg-[linear-gradient(90deg,#2a2e39,#151820)]" />
          <span aria-hidden className="absolute -left-[3px] top-[38%] h-[9%] w-[3px] rounded-l-[2px] bg-[linear-gradient(90deg,#2a2e39,#151820)]" />
          <span aria-hidden className="absolute -right-[3px] top-[30%] h-[14%] w-[3px] rounded-r-[2px] bg-[linear-gradient(270deg,#2a2e39,#151820)]" />
          <div className="relative rounded-[16cqw] border border-white/[0.12] bg-[linear-gradient(160deg,#1a1d26,#0b0d12_40%,#12141b)] p-[3.4cqw] shadow-[inset_0_0_0_1.5px_#05060a,inset_0_1px_0_1.5px_rgb(255_255_255/0.08),0_40px_80px_-40px_rgb(0_0_0/0.9)]">
            <div
              style={{ aspectRatio: aspectRatio ?? "9 / 19.5" }}
              className={cn("relative isolate overflow-hidden rounded-[12.6cqw] bg-[#05060a] [container-type:inline-size]", screenClassName)}
            >
              <div className="relative size-full">{children}</div>
              {time !== undefined && (
                <div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex h-[13cqw] items-center justify-between px-[8.5cqw] pt-[1cqw] text-[4.6cqw] font-semibold text-white/90">
                  <span className="tabular-nums">{time}</span>
                  <span aria-hidden className="flex items-center gap-[1.6cqw]">
                    <Wifi className="size-[4.6cqw]" strokeWidth={2.25} />
                    <span className="relative h-[3.6cqw] w-[7.4cqw] rounded-[1.1cqw] border border-white/60 p-[0.5cqw]">
                      <span className="block h-full w-[72%] rounded-[0.5cqw] bg-white/90" />
                    </span>
                  </span>
                </div>
              )}
              {cutout === "island" && (
                <span aria-hidden className="absolute left-1/2 top-[3.4cqw] z-20 h-[8.8cqw] w-[30cqw] -translate-x-1/2 rounded-full bg-black">
                  <span className="absolute right-[3cqw] top-1/2 size-[3cqw] -translate-y-1/2 rounded-full bg-[radial-gradient(circle_at_35%_35%,#2b3550,#0a0c12_60%)]" />
                </span>
              )}
              {cutout === "notch" && (
                <span aria-hidden className="absolute left-1/2 top-0 z-20 h-[9cqw] w-[44cqw] -translate-x-1/2 rounded-b-[5cqw] bg-black" />
              )}
              <span aria-hidden className="pointer-events-none absolute bottom-[2.2cqw] left-1/2 z-20 h-[1.4cqw] w-[36cqw] -translate-x-1/2 rounded-full bg-white/55" />
              {lit && (
                <>
                  <span aria-hidden className={rim} />
                  <span aria-hidden className={sheen} />
                </>
              )}
            </div>
          </div>
        </div>
      );
    }

    const hasTabs = !!tabs?.length;
    const lights = (
      <div aria-hidden className="flex shrink-0 items-center gap-2">
        {dots[controls].map((dot, i) => (
          <span key={i} className={cn("size-3 rounded-full shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.35)]", dot)} />
        ))}
      </div>
    );

    return (
      <div
        ref={ref}
        style={rootStyle}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative w-full overflow-hidden rounded-xl border border-white/10 bg-[#0c0e14] shadow-[0_1px_0_rgb(255_255_255/0.06)_inset,0_40px_90px_-40px_rgb(0_0_0/0.9)]",
          className,
        )}
        {...props}
      >
        {hasTabs ? (
          <>
            <div className="flex h-10 items-end gap-3 bg-[#08090e] pl-4 pr-2">
              <div className="flex h-full items-center">{lights}</div>
              <div className="flex min-w-0 flex-1 items-end" role="presentation">
                {tabs!.map((tab, i) => (
                  <div
                    key={`${i}-${tab}`}
                    className={cn(
                      "relative h-8 min-w-0 max-w-[13rem] flex-1 truncate rounded-t-lg px-3 pt-[7px] text-[12px]",
                      i === activeTab ? "bg-[#0c0e14] text-white/85" : "text-white/45",
                    )}
                  >
                    {tab}
                    {i !== activeTab && i + 1 !== activeTab && i < tabs!.length - 1 && (
                      <span aria-hidden className="absolute right-0 top-2 h-4 w-px bg-white/10" />
                    )}
                  </div>
                ))}
              </div>
            </div>
            <div className="flex h-10 items-center gap-2 border-b border-white/[0.06] px-3">
              <span aria-hidden className="flex items-center gap-1 text-white/35">
                <ChevronLeft className="size-4" />
                <ChevronRight className="size-4" />
                <RotateCw className="ml-1 size-3.5" />
              </span>
              {url && <UrlBar url={url} wide />}
            </div>
          </>
        ) : (
          <div className="flex h-10 items-center gap-3 border-b border-white/[0.06] px-4">
            {lights}
            {url && (
              <>
                <div className="flex min-w-0 flex-1 justify-center">
                  <UrlBar url={url} />
                </div>
                {/* Balances the window buttons so the address sits in the middle of the window. */}
                <span aria-hidden className="w-[52px] shrink-[4]" />
              </>
            )}
          </div>
        )}
        <div
          style={{ aspectRatio: aspectRatio ?? "16 / 10" }}
          className={cn("relative isolate overflow-hidden bg-[#05060a] [container-type:inline-size]", screenClassName)}
        >
          <div className="relative size-full">{children}</div>
          {lit && <span aria-hidden className={sheen} />}
        </div>
        {lit && <span aria-hidden className="pointer-events-none absolute inset-0 z-30 rounded-[inherit] shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.16)]" />}
      </div>
    );
  },
);
DeviceFrame.displayName = "DeviceFrame";

export { DeviceFrame };
```
