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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/notch-inset-layout.jsonRequired 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 probe —
env()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 CSSmax(), which is why it is right on the first paint and on hardware that shipped after the component did.
Status Bar Mock
A mocked OS status bar — clock, cellular, Wi-Fi, battery — that fills the safe-area band above a phone screen, with notch, Dynamic Island, punch-hole and flat glass.
Screen Transition
A push/pop screen stack with an interactive left-edge back swipe, a parallaxing under-layer and a drag that stays cancellable until you let go.