Skip to content
Frames & Mocks primitive

Phone & Tablet Frame

One handset bezel with the cut-out as a prop, so iPhone, Android and tablet are three settings rather than three assets. Status bar and home indicator included, and the clock is a prop so it can never be a hydration mismatch.

145 linesNo dependenciesAdded 13 Sept 2026
  • phone
  • mobile
  • mockup
  • device
  • ios
  • android

What's included

  • components/ui/phone-frame.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add phone-frame

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

12:30
12:30

Source

One file. Create components/ui/phone-frame.tsx and paste.

components/ui/phone-frame.tsx
/**
 * <PhoneFrame> — a handset or tablet bezel around your own content.
 *
 * The set everyone ships is three assets: iPhone, Android, iPad. They are
 * three assets because they were drawn three times. They are not three
 * *things*: the difference between a modern iPhone and a modern Android
 * handset is the cut-out at the top and about four pixels of corner radius,
 * and a tablet is the same object with a different aspect ratio and no
 * cut-out at all. Drawn once with those as props, the file count goes from
 * three to one and "Pixel with a punch-hole on the left" stops being a
 * request somebody has to fill.
 *
 * Like `<BrowserFrame>`, it takes `children` rather than an image source,
 * so a live component can sit inside it.
 *
 * The status bar is drawn rather than typed. A hard-coded "9:41" is a
 * trademark-adjacent detail of one vendor's marketing, and a real clock
 * would be a hydration mismatch on every server-rendered page (see the same
 * trap in `maintenance-window-state`). `time` is a prop with a neutral
 * default, so it is the caller's decision and always the same on both sides
 * of the render.
 */

import * as React from 'react'

export interface PhoneFrameProps {
  children?: React.ReactNode
  /** Handset or tablet. Changes the aspect ratio and the corner radius. */
  device?: 'phone' | 'tablet'
  /**
   * The cut-out at the top of the screen. This — not the case — is what
   * makes a frame read as one platform or another.
   */
  cutout?: 'island' | 'notch' | 'punch' | 'none'
  /** Draw the status bar and home indicator. */
  chrome?: boolean
  /** Shown at the left of the status bar. Never derived from the clock. */
  time?: string
  /** Rendered width. The height follows from the device's aspect ratio. */
  width?: number
  /** Side buttons on the case. Off for a flat "screen only" look. */
  buttons?: boolean
  className?: string
}

export function PhoneFrame({
  children,
  device = 'phone',
  cutout = 'island',
  chrome = true,
  time = '12:30',
  width = 260,
  buttons = true,
  className = '',
}: PhoneFrameProps) {
  const tablet = device === 'tablet'

  return (
    <div
      className={`relative shrink-0 ${className}`}
      style={{ width }}
      // The case, the buttons and the bars are all decoration wrapped around
      // whatever the caller passed. Only the children carry meaning, so the
      // decoration is hidden and the children are not — which is why
      // `aria-hidden` is on the individual pieces and never on this element.
    >
      {buttons ? (
        <>
          <span
            aria-hidden
            className="absolute -left-[3px] top-[18%] h-[6%] w-[3px] rounded-l-sm bg-foreground/25 border border-transparent"
          />
          <span
            aria-hidden
            className="absolute -left-[3px] top-[28%] h-[9%] w-[3px] rounded-l-sm bg-foreground/25 border border-transparent"
          />
          <span
            aria-hidden
            className="absolute -right-[3px] top-[24%] h-[12%] w-[3px] rounded-r-sm bg-foreground/25 border border-transparent"
          />
        </>
      ) : null}

      <div
        className={`relative overflow-hidden border-[6px] border-foreground/85 bg-foreground/85 shadow-xl ${
          tablet ? 'rounded-[1.75rem]' : 'rounded-[2.25rem]'
        }`}
      >
        <div
          className={`relative w-full overflow-hidden bg-background ${
            tablet ? 'aspect-[3/4] rounded-[1.3rem]' : 'aspect-[9/19.5] rounded-[1.8rem]'
          }`}
        >
          {chrome ? (
            <div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-5 pt-2 text-[10px] font-medium text-foreground">
              <span>{time}</span>
              <span aria-hidden className="flex items-center gap-1">
                {/* Signal, wifi and battery as shapes — three glyph imports
                    for a decorative strip is a dependency the tier does not
                    need to take on. */}
                <span className="flex items-end gap-[1.5px]">
                  <span className="h-1 w-[2px] rounded-sm bg-current" />
                  <span className="h-[5px] w-[2px] rounded-sm bg-current" />
                  <span className="h-[7px] w-[2px] rounded-sm bg-current" />
                </span>
                <span className="ms-0.5 h-2 w-3.5 rounded-[3px] border border-current p-[1.5px]">
                  <span className="block h-full w-2/3 rounded-[1px] bg-current" />
                </span>
              </span>
            </div>
          ) : null}

          {cutout === 'island' ? (
            <span
              aria-hidden
              className="absolute left-1/2 top-2 z-30 h-6 w-20 -translate-x-1/2 rounded-full bg-foreground/85 border border-transparent"
            />
          ) : null}
          {cutout === 'notch' ? (
            <span
              aria-hidden
              className="absolute left-1/2 top-0 z-30 h-6 w-32 -translate-x-1/2 rounded-b-2xl bg-foreground/85 border border-transparent"
            />
          ) : null}
          {cutout === 'punch' ? (
            <span
              aria-hidden
              className="absolute left-1/2 top-2.5 z-30 size-3 -translate-x-1/2 rounded-full bg-foreground/85 border border-transparent"
            />
          ) : null}

          <div className={`h-full w-full ${chrome ? 'pt-9' : ''}`}>{children}</div>

          {chrome && !tablet ? (
            <span
              aria-hidden
              className="absolute bottom-2 left-1/2 h-1 w-1/3 -translate-x-1/2 rounded-full bg-foreground/40 border border-transparent"
            />
          ) : null}
        </div>
      </div>
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
childrenReact.ReactNode—
deviceHandset or tablet. Changes the aspect ratio and the corner radius.'phone' | 'tablet''phone'
cutoutThe cut-out at the top of the screen. This — not the case — is what makes a frame read as one platform or another.'island' | 'notch' | 'punch' | 'none''island'
chromeDraw the status bar and home indicator.booleantrue
timeShown at the left of the status bar. Never derived from the clock.string'12:30'
widthRendered width. The height follows from the device's aspect ratio.number260
buttonsSide buttons on the case. Off for a flat "screen only" look.booleantrue
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More frames & mocks

View all
Open the full page for this primitive

Browser Frame

Desktop browser chrome that wraps whatever you put inside it — a screenshot, a video, or the live component itself. Three variants, no image asset, and the window controls are grey rather than a copy of one vendor’s.

Frames & Mocks139 lines1 dep
Open the full page for this primitive

Laptop Frame

A lid, a hinge and a tapered base, with the taper done by clip-path rather than the border trick that cannot round or carry a background. Degrades to a boxier laptop where clip-path is missing.

Frames & Mocks68 linesNo deps
Open the full page for this primitive

Device Showcase

The product-hero arrangement: a handset overlapping a laptop, overlapped with a negative inline-start margin so both stay in flow and the group keeps an honest height. Stacks below sm instead of shrinking to nothing.

Frames & Mocks291 linesNo deps