npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pwa-safezone

v0.1.0

Published

Safe-area, viewport and themeable-decoration primitives for installable web apps (PWA). React-Native-Safe-Area-Context-style API, web-native.

Readme

pwa-safezone

Safe-area, viewport and themable-decoration primitives for installable web apps (PWAs).

A web-native counterpart to react-native-safe-area-context — same shape (SafeAreaProvider, SafeAreaView, useSafeAreaInsets), plus the things only the web needs:

  • Viewport-unit gymnastics (vh vs dvh vs lvh vs svh).
  • Display-mode detection (browser / standalone / fullscreen / …).
  • The iOS standalone scroll-lock that doesn't break the layout viewport.
  • Themable strips for the Dynamic Island / notch and home-indicator zones.
  • A <SafeShell> root that anchors to the real device edges, working around the iOS PWA inset:0 bug.
npm install pwa-safezone

Prerequisite: the page must declare viewport-fit=cover or every env(safe-area-inset-*) value will be 0px and the library has nothing to inset against.

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

Quick start (the one-import path)

// app/layout.tsx (Next.js) — or your root component
import "pwa-safezone/css"; // scroll-lock reset + utility classes (opt-in)
import { SafeAreaProvider, SafeShell } from "pwa-safezone";

const THEME = "#0b1220";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <SafeAreaProvider>
          <SafeShell
            background={THEME}
            islandBackground={THEME}
            homeIndicatorBackground={THEME}
          >
            {children}
          </SafeShell>
        </SafeAreaProvider>
      </body>
    </html>
  );
}

That single component:

  1. Anchors to the real device edges (top:0; left:0; width:100vw; height:100lvh), sidestepping the iOS PWA bug where position:fixed; inset:0 lands inside an oversized layout viewport.
  2. Paints the Dynamic Island and home-indicator bands with your theme colour so the app looks edge-to-edge.
  3. Pads its children by env(safe-area-inset-*) so nothing slides under the notch or the home-indicator pill.

<SafeShell> is height:100lvh; overflow:hidden. If you want a scrolling content area, wrap the children in <div style={{ overflow: "auto", height: "100%" }}>.

The mental model: three independent jobs

The library does three things. You can use any of them in isolation, or stack them.

| Job | What it does | Building block | |---|---|---| | Paint the unsafe bands | Fills the strip above the layout viewport (Dynamic Island / notch) and below it (home-indicator pill / URL bar) with a theme colour. Pointer-events: none by default. | <DynamicIslandZone> / <HomeIndicatorZone> | | Inset your content | Pushes content in by env(safe-area-inset-*) so text and tap targets clear the notch and home indicator. | <SafeAreaView> (or useSafeAreaInsets() for manual control) | | Anchor the root | Replaces the iOS standalone inset:0 trap with top:0; left:0; width:100vw; height:100lvh so the shell touches the device edges, not the (sometimes oversized) layout viewport. | <SafeShell> |

<SafeShell> is just a convenience that does all three. If you need finer control, drop down to the individual pieces.

Tiered recipes

Tier 1 — Full screen app with a single theme colour

The Quick Start above. Five lines. Use this unless you have a reason not to.

Tier 2 — Header + scrolling body + bottom nav

When you have separately-coloured chrome (e.g. a dark header above a light body), let <SafeShell> handle the side notches and the painted strips, and inset header/footer individually with <SafeAreaView>.

import {
  SafeAreaProvider, SafeShell, SafeAreaView,
} from "pwa-safezone";

const HEADER_BG = "#0b1220";
const BODY_BG   = "#f8fafc";
const NAV_BG    = "#0b1220";

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SafeAreaProvider>
      <SafeShell
        edges={["left", "right"]}            // shell handles side notches only
        background={BODY_BG}
        islandBackground={HEADER_BG}         // paint the top strip
        homeIndicatorBackground={NAV_BG}     // paint the bottom strip
      >
        <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
          {/* Header sits below the notch */}
          <SafeAreaView edges={["top"]} style={{ background: HEADER_BG, color: "#fff" }}>
            <header style={{ padding: "12px 16px" }}>My App</header>
          </SafeAreaView>

          {/* Scrolling body */}
          <main style={{ flex: 1, overflow: "auto", background: BODY_BG }}>
            {children}
          </main>

          {/* Bottom nav clears the home indicator (≥34px on iPhone) */}
          <SafeAreaView
            edges={{ bottom: "maximum" }}
            padding={{ bottom: 8 }}
            style={{ background: NAV_BG, color: "#fff" }}
          >
            <nav style={{ display: "flex", justifyContent: "space-around", padding: 8 }}>
              {/* … */}
            </nav>
          </SafeAreaView>
        </div>
      </SafeShell>
    </SafeAreaProvider>
  );
}

Pattern: paint with the Zones, inset with <SafeAreaView>. Each chrome element gets the inset for its own edge; the body doesn't need any.

Tier 3 — Bare primitives (canvas, video, custom overlays)

Skip <SafeShell> and <SafeAreaView>, read insets directly:

import { SafeAreaProvider, useSafeAreaInsets } from "pwa-safezone";

function PlayerOverlay() {
  const { top, bottom, left, right } = useSafeAreaInsets();
  return (
    <div style={{
      position: "fixed",
      top, left,
      width:  `calc(100vw - ${left + right}px)`,
      height: `calc(100lvh - ${top + bottom}px)`,
    }}>
      {/* controls */}
    </div>
  );
}

The full list of primitives is in API reference below.

Theming the zones

<DynamicIslandZone> and <HomeIndicatorZone> are fully symmetric. Same props, same capabilities.

| Prop | Type | Default | Both | Notes | |---|---|---|---|---| | background | any CSS background: value | — | yes | Solid, gradient, image. | | interactive | boolean | false | yes | Default pointer-events:none. Set true for clickable content inside the band. | | zIndex | number | 9999 | yes | | | style | CSSProperties | — | yes | Merged after positioning. Use for backdropFilter, boxShadow, transitions. | | children | ReactNode | — | yes | Renders inside the band. | | source | "lvh-svh" | "safe-bottom" | "lvh-svh" | bottom only | lvh-svh = the band between position:fixed; bottom:0 and the real device edge (works in Safari URL-bar mode and PWA home-indicator). safe-bottom = strictly env(safe-area-inset-bottom). |

Examples — both ends

// 1. Solid theme colour (the 90% case)
<DynamicIslandZone background="var(--header-bg)" />
<HomeIndicatorZone background="var(--nav-bg)" />

// 2. Gradient fade (glassy header look)
<DynamicIslandZone
  background="linear-gradient(180deg, rgba(11,18,32,.95), rgba(11,18,32,.6))"
/>

// 3. Frosted / blurred band (iOS-style)
<HomeIndicatorZone
  background="rgba(11,18,32,.55)"
  style={{
    backdropFilter: "blur(20px)",
    WebkitBackdropFilter: "blur(20px)",
  }}
/>

// 4. Live status pill inside the Dynamic Island band
<DynamicIslandZone background="#000" interactive>
  <div style={{
    display: "flex", alignItems: "center", justifyContent: "center",
    height: "100%", color: "#fff", fontSize: 12,
  }}>
    ● Recording
  </div>
</DynamicIslandZone>

// 5. Animate the strip (Framer Motion, WAAPI, CSS transitions — all work)
<DynamicIslandZone background={headerColour} style={{ transition: "background .3s" }} />

Choosing the right source on the bottom zone

  • lvh-svh (default) — populated whenever the visual viewport is smaller than the largest viewport. That includes both (a) Safari with the URL bar visible (~79px on a modern iPhone), and (b) a standalone PWA below the home-indicator pill (~34px). Use this if you want the band to fill whenever there's an unsafe area, regardless of cause.
  • safe-bottom — strictly the iOS home-indicator reservation via env(safe-area-inset-bottom). Zero in plain Safari; ~34px in standalone. Use this if you only want the band to appear in the installed PWA.

Both strips render nothing visible when their measured height is 0, so they're safe to mount on desktop, Android, and any non-PWA context.

API reference

Components

| Export | Purpose | |---|---| | <SafeAreaProvider> | Required ancestor. Sets up the insets context and observes for changes. Same prop shape as RNSAC, including initialMetrics. | | <SafeShell> | One-stop root: anchors to device edges, paints both zones, insets children. Start here. | | <SafeAreaView edges mode padding> | Pads (or margins) its children by the safe-area inset on the specified edges. edges accepts ["top","bottom"] or { top: "additive", bottom: "maximum" }. | | <DynamicIslandZone> | Paints the top unsafe band (env(safe-area-inset-top)). | | <HomeIndicatorZone> | Paints the bottom unsafe band (100lvh − 100svh or env(safe-area-inset-bottom)). | | <ViewportMatrix> | Drop-in debug page showing every viewport metric the browser reports, with coloured reference strips. Mount on a /debug route in dev. |

Hooks

| Hook | Returns | |---|---| | useSafeAreaInsets() | { top, right, bottom, left } in CSS px. | | useSafeAreaFrame() | { x, y, width, height } — the safe rectangle. | | useViewportSnapshot() | Full live ViewportSnapshot (display mode, every viewport unit, fixed rect, etc.). | | useDisplayMode() | "browser" | "standalone" | "fullscreen" | "minimal-ui" | "window-controls-overlay" | "unknown" | "ssr". | | useIsStandalone() | boolean. | | useIsSecureContext() | boolean. |

Framework-agnostic core (works in vanilla JS, Vue, Svelte, …)

| Function | Returns | |---|---| | takeSnapshot() | A full ViewportSnapshot at call time. | | measureSafeAreaInset("top" \| "right" \| "bottom" \| "left") | Single inset in CSS px. | | measureSafeAreaInsets() | All four. | | measureViewportUnit("height" \| "width", "100dvh") | Resolves any viewport unit to CSS px. | | measureFixedInsetRect() | Bounding rect of a position:fixed; inset:0 probe. Use this to detect the iOS layout-viewport detach bug. | | resolveCssLength("max(calc(env(safe-area-inset-top) + 1.125rem), 2rem)") | Resolves an arbitrary CSS length to CSS px. | | detectDisplayMode() / detectOrientation() | Sync detectors. |

Edge modes (compatible with react-native-safe-area-context)

| Mode | Meaning | |---|---| | "off" | Don't inset this edge. | | "additive" (default) | final = safeArea + your padding. | | "maximum" | final = max(safeArea, your padding). Useful for "at least 24px or the safe area, whichever is bigger" floating UI. |

CSS layer

import "pwa-safezone/css";

Optional — but recommended for any installable PWA. Two concatenated files:

  • 01-pwa-reset.css — installable-PWA scroll-lock. Only activates in display-mode: standalone or fullscreen, and puts position:fixed on <body>, never on <html>. (Putting it on <html> is what breaks the iOS layout viewport — that bug is the entire reason this library exists.)
  • 02-utilities.css — utility classes (.sz-pt-safe, .sz-home-indicator-zone, etc.) for when you don't want to reach for the React components.

The reset is safe to import on non-PWA pages — its rules are gated behind a display-mode media query.

API parity with react-native-safe-area-context

| react-native-safe-area-context | pwa-safezone | Notes | |---|---|---| | <SafeAreaProvider initialMetrics> | same | | | <SafeAreaView edges mode> | same | edges accepts the array form (['top','bottom']) and the object form ({ top: 'maximum', bottom: 'off' }). | | EdgeMode 'off' \| 'additive' \| 'maximum' | same | | | mode: 'padding' \| 'margin' | same | | | useSafeAreaInsets() | same | | | useSafeAreaFrame() | same | |

The drop-in pattern from RN ports to web almost line-for-line. The web extras (<SafeShell>, the zones, the snapshot/measure helpers, the display-mode hooks) sit alongside without conflict.

Why not just position:fixed; inset:0 on <html>?

Because on iOS Safari that detaches the layout viewport from the visual viewport. Symptoms:

  • window.innerWidth reports an oversized value (e.g. 524 on a 393pt-wide screen — exactly 4/3 zoom-out).
  • Descendant position:fixed; inset:0 boxes stop reaching the device edges.
  • env() insets are still correct, but anything aligned via inset:0 / right:0 / bottom:0 is wrong.

The reset in pwa-safezone/css puts the scroll-lock on <body> only. Don't undo it. And use <SafeShell> (or anchor with top:0; left:0; width:100vw; height:100lvh manually) instead of inset:0.

You can verify the bug is fixed in your app by mounting <ViewportMatrix> and checking the WINDOW row: innerWidth × innerHeight should match the device's CSS-px size (e.g. 393 × 852 on a Dynamic Island iPhone), and the POSITION:FIXED; INSET:0 RECT row's matches innerHeight? should report true.

Debugging

import { ViewportMatrix } from "pwa-safezone";

export default function DebugPage() {
  return <ViewportMatrix showReferenceStrips version="v1.4.0" />;
}

Mount it on a route only you can reach (e.g. /debug), open it on the device, take a screenshot. The page reports every viewport metric the browser will give you and overlays:

  • Red border — where position:fixed; inset:0 actually lands.
  • Green border — the safe-area rectangle.
  • Fuchsia fill at the bottom — the 100lvh − 100svh band (URL bar or home-indicator zone).

If the green and red rectangles don't agree on any edge, that edge has a non-zero inset — that's working as intended. If the red rectangle extends past the device, you have the iOS layout-viewport detach bug, and <SafeShell> will fix it.

Local development

The repo ships with a Vite playground that consumes the library straight from src/ (HMR, no rebuild needed) and binds to your LAN over HTTPS so you can test on a real iPhone:

npm install
npm run play:https     # https://<your-lan-ip>:5173/

On iOS, accept the self-signed cert warning once, then Share → Add to Home Screen and open from the icon to test in display-mode: standalone.

SSR & frameworks

All measurement functions return 0 / sensible defaults when window is undefined, so SSR / static rendering never throws. The components emit a "use client" banner for Next.js App Router compatibility. First real values arrive after hydration.

License

MIT