Safe Area
A wrapper that pads or offsets content by the OS safe-area insets — notch, rounded corners, home indicator, landscape side rails — with a max() floor for devices that report none.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/safe-area.jsonRequired page setup
env(safe-area-inset-*) resolves to 0px unless the document opts into the full screen. Without
this token the component is a silent no-op — it renders, it just never pads anything, on every
device:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />In a Next.js App Router project the same thing is a viewport export in the root layout:
import type { Viewport } from "next"
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
}Prompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SafeArea" component — a wrapper that keeps
content clear of the device's safe-area insets (notch / Dynamic Island, rounded
corners, home indicator, landscape side rails). No dependencies, no animation.
Contract
- Export a forwardRef<HTMLElement> component extending
React.HTMLAttributes<HTMLElement>; the root element is `as` (React.ElementType,
default "div") so it can be a header/footer/nav/main.
- edges: ("top" | "right" | "bottom" | "left")[] — which physical edges get the
inset. Default: all four.
- mode: "padding" | "margin" (default "padding").
- min: number | string (default 0) — a floor applied to every listed edge.
Number means px; strings pass through ("1rem", "var(--gutter)", "calc(...)").
- Remaining props spread onto the root; consumer className is merged with cn();
consumer `style` is spread AFTER the generated inset styles so it always wins.
Behavior
- For each listed edge write ONE longhand — paddingTop/…/paddingLeft in
"padding" mode, marginTop/…/marginLeft in "margin" mode — with the value
`max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), <min>)`.
Edges that were not listed are not written at all, so a className like "px-4"
on those edges keeps working; on the edges you DO list the component owns the
longhand, so express the base value with `min` instead of a p-* class.
- The var() indirection is deliberate: setting --safe-area-inset-top/right/
bottom/left on any ancestor overrides or simulates the OS value (device-frame
previews, desktop development, screenshot tests) without touching a component.
- min clamping: a number is emitted as "<n>px" with 0 / negative / NaN /
Infinity collapsed to "0px". This matters — "NaNpx" would make the whole
declaration invalid and drop the inset entirely, which is exactly the silent
failure the component exists to prevent. A string min is trimmed and falls
back to "0px" when empty.
- Never read the insets from JavaScript. env() is not exposed to script; there
is no matchMedia, no ResizeObserver, no state. That is what keeps this a
server component — do NOT add "use client".
- The insets are viewport geometry, not element geometry: env() returns the same
number no matter where the element sits, so only wrap elements that really
touch the physical edge.
Rendering & styling
- The component paints nothing of its own: no background, no border, no radius,
no color — semantic tokens are whatever the children already use. It only
writes up to four length declarations.
- No ARIA: it is a layout wrapper, not a widget; it adds no role and does not
interfere with the focus order. Nothing animates, so prefers-reduced-motion is
not applicable.
- Consumer setup (state this in your docs, it is the number one failure mode):
`<meta name="viewport" content="width=device-width, initial-scale=1,
viewport-fit=cover">` — without it every inset is 0 and the wrapper does
nothing at all. In Next.js: `export const viewport: Viewport = { viewportFit:
"cover" }`.
Composition rules worth documenting
- Bar pinned to the bottom: wrap the BAR in "padding" mode so its background
bleeds under the home indicator while its controls sit above it. Never use
"margin" on a position:fixed bottom-0 bar — the margin lifts it and exposes
the page behind it.
- Scroll containers: the inset must go on the scrolled CONTENT, not around the
scroll box. A pinned bar needs bar height + inset of clearance, which is a
SUM, and max() cannot express a sum — put a fixed-height spacer as the last
child of the scrolled content and wrap that spacer in SafeArea, so its total
height becomes height + inset. Padding the scroll box from the outside leaves
the last row permanently under the bar. Match the bar's OUTER height, border
included: a 1px border-t on the bar and not on the spacer leaves the last row
1px short (measured, not guessed). Keeping the border on the bar's inner row
makes the two heights line up by construction.
- Portrait side insets are 0 even on a notched phone (the rounded corners are
covered by the top inset, not by left/right ones). If you want content held
off the corner radius in portrait, that is what `min` is for.
Customization levers
- Which edges: default all four for a full-screen shell; ["bottom"] for a tab
bar or a sticky action row; ["top"] for a translucent header; ["left","right"]
for landscape rails only.
- Floor: min=0 keeps the wrapper purely additive (desktop looks untouched);
min=12–16 turns it into "at least this much padding, more where the OS needs
it". For asymmetric floors nest two SafeAreas — one for the x edges, one for
the y edges — each with its own min.
- Mode: "padding" when the wrapped box has its own background that should reach
the physical edge; "margin" for a floating card, toast or FAB that should keep
a gap outside its own border.
- Element: as="header" / "footer" / "nav" / "main" keeps the landmark semantics
instead of adding a wrapper div.
- Override hook: rename --safe-area-inset-* if it collides with your own naming,
or set it on a device-frame preview container to demo notched layouts on a
desktop browser.Concepts
viewport-fit=coveris the on switch — the browser only reports non-zero insets once the document asks to draw under the notch and the home indicator. Ship the meta tag (or the Next.jsviewportexport) or this component, and every hand-writtenenv()in your codebase, quietly resolves to zero on real hardware.- Insets are viewport geometry, not element geometry —
env(safe-area-inset-bottom)returns the device's bottom inset no matter where the element sits in the document, so a wrapper in the middle of a page gets the same 34px as one pinned to the bottom. Only wrap what actually touches the physical edge. max()is the floor, not the fallback — desktop browsers, portrait side edges and any page withoutviewport-fit=coverall report0px.max(inset, min)is the one place to say "at least this much"; with the defaultminof0the wrapper stays purely additive and changes nothing on a desktop layout.- Padding bleeds, margin retreats — the same inset either grows the box (its own background reaches the physical edge, which is what a bottom bar wants) or pushes it away (a gap outside its border, which is what a floating card wants). A bottom margin on a
position: fixed; bottom: 0bar lifts the bar and exposes the page underneath it. - Clearance is a sum, insets are a max — content scrolling under a pinned bar needs bar height + inset, and
max()cannot express a sum. The pattern is a fixed-height spacer as the last child of the scrolled content, wrapped in Safe Area: its height clears the bar, the padding adds the inset. Padding the scroll box from the outside leaves the last row permanently buried — measured at 48px of overlap in the preview above, exactly the bar's own height. The spacer has to match the bar's outer height, borders included. - CSS-only, therefore a server component — the insets are not readable from JavaScript, so there is no measuring, no state and no
"use client". The one escape hatch is a custom property:--safe-area-inset-*set on any ancestor wins overenv(), which is how the preview above simulates a notched phone inside a desktop browser.
Sticky Stack
Multi-level sticky section headers that park and shove one another, with every parking line accumulated from the measured heights of the headers above it.
Client Only
A wrapper that renders its children only after mount — the server and the first hydration render both emit the fallback, so browser-only values can never produce a hydration mismatch.