Mobile

Notch Inset Layout

A screen shell that lays itself out from the insets the device actually reports — filling, passing under, or stepping away from the notch, island, corner arcs and home-indicator lane.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type NotchInsetEdge = "top" | "right" | "bottom" | "left"

/**
 * What the measured insets say the screen physically is. `"unknown"` is the
 * pre-measurement value: it is what the server renders and what the first client
 * frame renders, so the two always agree.
 */
export type NotchShape = "unknown" | "flat" | "notch" | "island" | "rail"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/notch-inset-layout.json

Required page setup

env(safe-area-inset-*) resolves to 0px unless the document opts into the full screen. Without this token every gap falls back to the gutter floor and the component is an ordinary flex shell, on every device:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

Prompt

Build a React + TypeScript + Tailwind "NotchInsetLayout" component (React 18+, lucide-react optional,
a cn() classname merger). It is a mobile screen shell that lays itself out from the insets the device
reports, not from a list of phones.

Contract
- export const NotchInsetLayout = React.forwardRef<HTMLElement, NotchInsetLayoutProps>(...)
  rendering a <section> that spreads the remaining native props.
- export type NotchInsetEdge = "top" | "right" | "bottom" | "left"
- export type NotchShape = "unknown" | "flat" | "notch" | "island" | "rail"
- export type NotchInsetVariant = "bar" | "immersive" | "card"
- export interface NotchInsets { top; right; bottom; left } — all numbers, px.
- Props:
  - variant?: NotchInsetVariant = "bar"
  - title?, leading?, trailing?, footer?: React.ReactNode — the header row is title + two clusters;
    the props interface must Omit the native "title" attribute so the slot can be a node.
  - cornerRadius?: number = 0 — the screen's physical corner radius. The OS never reports it, so this
    is a declaration, not a measurement. Its meaning differs per variant: in "bar"/"immersive" it
    drives the corner-arc padding on the left and right of every row; in "card" it additionally sets
    the card's concentric radius. It also clips the shell's own content.
  - insets?: Partial<Record<NotchInsetEdge, number>> — override/simulate. Only the listed edges are
    overridden; the rest keep their real env() value.
  - minGutter?: number = 12 — floor under every chrome gap.
  - showGuides?: boolean = false — development overlay.
  - onShapeChange?: (shape: NotchShape, insets: NotchInsets) => void
  - label?: string — naming the shell turns it into a region landmark; unnamed by default.
- No controlled/uncontrolled pair: the component owns no user-editable state. The only state it holds
  is the measurement, which belongs to the device, not to the consumer.

Behavior
- Geometry is CSS, classification is JS. Every gap is a max() over
  var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), so the layout is correct on the
  very first paint — before hydration, and on hardware the component has never heard of. Measured
  numbers only name the shape; nothing moves because of them, so there is no reflow flash.
- The four resolved lengths are published as custom properties on the shell and are the documented
  extension points: --notch-<edge> (raw), --notch-edge-<edge> (where the hardware ends: left/right
  are raised to the corner arc), --notch-pad-<edge> (where chrome may start: the edge floored at
  --notch-gutter), plus --notch-corner and --notch-arc.
- Corner arc = cornerRadius * 0.2929 (1 - 1/sqrt(2)) — how far in from the corner a radius-r arc
  actually bites. It is applied to left and right only: the arc eats the ends of a row, not the
  middle of the top edge.
- Two different numbers, deliberately: content is pushed in by exactly what the hardware took
  (raised to the arc) so a full-bleed list stays full bleed; chrome takes max(that, minGutter) so a
  bar still breathes on a screen that reports nothing.
- Nothing interactive ever goes inside the top inset. That strip belongs to the system status bar —
  clock one side, battery the other. Chrome may paint its background there; it may not put a control
  there. Same at the bottom: the footer's background enters the home-indicator lane, its controls
  stop above it.
- Variants:
  - "bar" — header wash fills the top strip, footer background fills the lane, content scrolls
    between them. With no footer the content region takes the bottom padding instead; with no
    header it takes the top padding.
  - "immersive" — content is full bleed under the cutout and the indicator; header and footer float
    over a background-to-transparent scrim. The scrim is pointer-events:none and the rows re-enable
    pointers, so a drag starting on the scrim still scrolls the content beneath.
  - "card" — the shell is padded by the insets and the screen is drawn as one rounded surface. Its
    radius is concentric: max(0.75rem, cornerRadius - the gap it was pushed in by), so it never
    squares off. Inner rows use the plain gutter — adding the inset again would double it.
- Measurement: env() is not exposed to script, so a 0x0 aria-hidden probe carries the four lengths
  as padding and getComputedStyle hands them back as numbers. Its border box is the sum of the
  insets, so a ResizeObserver on the border box catches every change, including the ones no window
  event announces; the observer's first delivery IS the first measurement, so nothing is read in the
  effect body and no setState happens there. window resize + orientationchange are the backstop for
  the one case the observer misses: rotating between the two landscapes swaps left and right without
  changing their sum. All three are coalesced into one rAF, and identical readings keep the previous
  object so the shape callback does not re-fire.
- Classification, in this order: left or right >= 16 -> "rail" (in landscape the cutout leaves the
  top edge); top >= 54 -> "island"; top >= 30 -> "notch"; otherwise "flat". Before the first
  measurement the shape is "unknown" — the same value on the server and on the first client frame,
  so hydration never mismatches. Published as data-shape and through onShapeChange, which is called
  through a latest-ref so an inline arrow does not re-fire it every render.
- Edge cases: an empty leading/trailing cluster still reserves 44px so the title stays optically
  centred; a title longer than the row truncates on one line and never grows the bar; slots are
  tested for renderable content, not truthiness, so a title of 0 counts; overrides are clamped to
  0..240px because a NaN would serialise to "NaNpx" and silently drop the declaration; a partial
  insets override leaves the other edges on their real env() value.
- Cleanup: the rAF is cancelled, the observer disconnected and both window listeners removed on
  unmount.
- There is no gesture and nothing hover-only: the shell is structure, and every control in it is the
  consumer's own, reached by tab and by touch.

Rendering & styling
- Semantic tokens only: bg-background / bg-card / bg-muted / text-foreground / text-muted-foreground
  / border, with bg-foreground text-background for the one high-priority chip in the guides overlay.
  No hex, no rgb(), no colour for decoration.
- Root: relative isolate flex h-full flex-col overflow-hidden, border-radius var(--notch-corner) so
  full-bleed content squares off exactly where the glass does. Give it a height (h-dvh on a real
  screen); it fills its parent otherwise.
- Rows are min-h-14 with 44px minimum clusters; the title is a single truncated line at ~15px/600.
- The only motion is a 200ms ease-out transition on the height and padding that change when the
  device rotates, disabled under motion-reduce. Nothing depends on it.
- Accessibility: the shell is a <section>, so its <header> and <footer> are scoped and do not become
  page-level banner/contentinfo landmarks — ten shells on a page pollute nothing. Naming it with
  `label` promotes it to a region. Do not put <main> inside the component; put it in your children if
  this shell is the page. The probe, the header wash and the guides overlay are aria-hidden.
- The override property is --safe-area-inset-<edge>, the same one a plain safe-area wrapper reads, so
  setting it on any ancestor simulates a device for everything inside.

Customization levers
- variant picks the whole treatment: fill it ("bar"), pass under it ("immersive"), step away from it
  ("card"). Adding a fourth means adding a branch, not new props.
- minGutter sets the density of all chrome at once; cornerRadius controls both the arc padding and
  the concentric card radius; set it to the real device radius when simulating, 0 on hardware.
- The classification thresholds (54 / 30 / 16 px) are module constants — move them if you target
  hardware that reports differently; the layout does not depend on them.
- Restyle by overriding --notch-pad-<edge> or --notch-edge-<edge> on the shell rather than patching
  the rows, and drop the guides overlay entirely for production if you never develop against it.
- Slots are free-form nodes: a tab bar in `footer`, a search field in `title`, nothing at all in
  either — the shell adapts its padding to whichever slots are present.

Concepts

  • Status-bar strip — the top inset is not free real estate: the system paints the clock and the battery over it. Chrome may bleed its background up there, but a control placed inside it is a control nobody can read or reliably hit.
  • Corner arc, 0.29r — a corner of radius r does not eat r from a row; it eats r · (1 − 1/√2), about 0.29r. Padding rows by the full radius wastes a third of the width, padding them by zero clips the first character on a 55px-radius screen.
  • Home-indicator lane — the bottom inset is a gesture region. The rule that makes a footer feel native is that its background enters the lane while its tap targets stop above it, so the bar looks attached to the edge without competing with swipe-up-to-home.
  • Concentric radius — a surface inset by d inside a screen of radius r must round to r − d, not to r. Equal radii on nested rounded boxes read as a mistake; concentric ones read as one object.
  • Inset probeenv() exists only inside CSS, so the only way to learn the numbers in JavaScript is to spend them: a zero-sized element carries them as padding, and its border box is their sum, which makes a ResizeObserver a change feed for the device's own geometry.
  • Classification, not measurement-driven layout — the measured numbers name the shape (flat / notch / island / rail) for callbacks and CSS hooks. The layout itself stays in CSS max(), which is why it is right on the first paint and on hardware that shipped after the component did.

On This Page