Display

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.

Preview in your theme

Loading preview…

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

export type SafeAreaEdge = "top" | "right" | "bottom" | "left"
export type SafeAreaMode = "padding" | "margin"

const ALL_EDGES: readonly SafeAreaEdge[] = ["top", "right", "bottom", "left"]

const STYLE_KEY = {
  padding: { top: "paddingTop", right: "paddingRight", bottom: "paddingBottom", left: "paddingLeft" },
  margin: { top: "marginTop", right: "marginRight", bottom: "marginBottom", left: "marginLeft" },
} as const

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/safe-area.json

Required 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=cover is 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.js viewport export) or this component, and every hand-written env() in your codebase, quietly resolves to zero on real hardware.
  • Insets are viewport geometry, not element geometryenv(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 without viewport-fit=cover all report 0px. max(inset, min) is the one place to say "at least this much"; with the default min of 0 the 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: 0 bar 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 over env(), which is how the preview above simulates a notched phone inside a desktop browser.

On This Page