07Notch safe-area padding — margin that pushes content clear of a screen's cut-out edges
Inside a frame simulating a screen with a notch and bottom bar, the content's bottom padding automatically grows to match that cut-out area, so no button is ever hidden behind it.
Best forA full-screen mobile layout, a bottom-fixed action bar
env(safe-area-inset-top)env(safe-area-inset-bottom)--safe-top

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="frameOuter">
<div class="notchTop" id="notchTop"></div>
<div class="frameInner">
<div class="content" id="content">
<span class="pill" id="pill">Content</span>
</div>
</div>
<div class="notchBottom" id="notchBottom"></div>
</div>
<input class="slider" id="slider" type="range" min="0" max="100" value="0" aria-label="Safe area simulation">
<div class="readout" id="readout">Top —px · bottom —px</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 apply() 에 있다 — 슬라이더 진행률(t)로 --safe-top/--safe-bottom
// 커스텀 프로퍼티를 실측 px 로 채우고 getComputedStyle 로 실제 렌더된 padding 을 다시 잰다.
// 실사용 코드는 env(safe-area-inset-*) 를 그대로 쓰고, 데스크톱은 그 값이 항상 0px 이라
// 시뮬레이션용 --safe-top/--safe-bottom 을 max() 로 겹쳐 잰다(데모 전용 값이지 대체가 아니다).
@include stage($stage-orange);
.wrap {
width: 300px;
display: flex;
flex-direction: column;
gap: $sp-3;
align-items: center;
}
.frameOuter {
width: 200px;
border-radius: $r-card;
border: 3px solid $subject-ink;
overflow: hidden;
background: $subject-ink;
}
.notchTop,
.notchBottom {
height: 10px;
background: rgba($subject-ink, .9);
display: flex;
justify-content: center;
&::after {
content: "";
width: 40px;
height: 6px;
border-radius: $r-pill;
background: $subject-cream;
opacity: .3;
align-self: center;
}
}
.frameInner {
background: $subject-cream;
min-height: 120px;
display: flex;
align-items: stretch;
}
.content {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
// 실사용 규칙 — 실제 기기 안전 영역과 시뮬레이션 값 중 큰 쪽을 쓴다
padding-top: max(var(--safe-top, 0px), env(safe-area-inset-top));
padding-bottom: max(var(--safe-bottom, 0px), env(safe-area-inset-bottom));
padding-left: $sp-3;
padding-right: $sp-3;
transition: padding $dur-base $ease-out;
}
.pill {
border-radius: $r-pill;
background: $subject-orange-deep;
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 700;
}
.slider {
width: 220px;
accent-color: $subject-orange-deep;
}
.readout {
color: $subject-ink;
font-size: 11px;
}
@media (max-width: 340px) {
.wrap { width: 260px; gap: $sp-2; }
.frameOuter, .slider { width: 170px; }
.frameInner { min-height: 84px; }
.notchTop, .notchBottom { height: 8px; }
}
@media (prefers-reduced-motion: reduce) {
// reduce: 자동 루프의 오가는 진행만 멈춘다(슬라이더 실사용 조작은 즉시 반영)
.content { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
width: 300px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.frameOuter {
width: 200px;
border-radius: 16px;
border: 3px solid #17141a;
overflow: hidden;
background: #17141a;
}
.notchTop,
.notchBottom {
height: 10px;
background: rgba(23, 20, 26, 0.9);
display: flex;
justify-content: center;
}
.notchTop::after,
.notchBottom::after {
content: "";
width: 40px;
height: 6px;
border-radius: 999px;
background: #fff7e6;
opacity: 0.3;
align-self: center;
}
.frameInner {
background: #fff7e6;
min-height: 120px;
display: flex;
align-items: stretch;
}
.content {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
padding-top: max(var(--safe-top, 0px), env(safe-area-inset-top));
padding-bottom: max(var(--safe-bottom, 0px), env(safe-area-inset-bottom));
padding-left: 12px;
padding-right: 12px;
transition: padding 300ms ease-out;
}
.pill {
border-radius: 999px;
background: #c93000;
color: #fff7e6;
padding: 4px 12px;
font-size: 11px;
font-weight: 700;
}
.slider {
width: 220px;
accent-color: #c93000;
}
.readout {
color: #17141a;
font-size: 11px;
}
@media (max-width: 340px) {
.wrap {
width: 260px;
gap: 8px;
}
.frameOuter, .slider {
width: 170px;
}
.frameInner {
min-height: 84px;
}
.notchTop, .notchBottom {
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.content {
transition: none;
}
}(function () {
var SAFE_AREA_SCALE = 1.0;
var STEP_MS_TOTAL = 2000;
var MAX_INSET_PX = 24;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var root = document.documentElement;
var slider = document.getElementById('slider');
var content = document.getElementById('content');
var readout = document.getElementById('readout');
var isDemo = true;
function apply(t) {
// t: 0~1, 시뮬레이션한 기기 안전 영역 진행률 — env() 변수를 실측 CSS 커스텀 프로퍼티로 대입한다
var top = Math.round(MAX_INSET_PX * t * SAFE_AREA_SCALE);
var bottom = Math.round(MAX_INSET_PX * t);
root.style.setProperty('--safe-top', top + 'px');
root.style.setProperty('--safe-bottom', bottom + 'px');
slider.value = String(Math.round(t * 100));
// 실측 — padding-top 이 실제로 몇 px 렌더됐는지 getComputedStyle 로 다시 잰다
var cs = getComputedStyle(content);
readout.textContent = 'Top ' + parseFloat(cs.paddingTop).toFixed(0) + 'px · bottom ' + parseFloat(cs.paddingBottom).toFixed(0) + 'px';
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
slider.addEventListener('input', function () {
human();
apply(Number(slider.value) / 100);
});
slider.addEventListener('pointerdown', human);
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo && !reduce) {
var phase = ((ts - start) % STEP_MS_TOTAL) / STEP_MS_TOTAL;
var t = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
apply(t);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SafeArea — vanilla 판(run/376/demo/07-safe-area/index.html) 의 React 포트.
// 슬라이더 진행률(t)로 --safe-top/--safe-bottom 커스텀 프로퍼티를 실측 px 로 채우고
// getComputedStyle 로 실제 렌더된 padding 을 다시 잰다. 실사용 padding 규칙은 CSS 의
// env(safe-area-inset-*) 를 그대로 쓰고 --safe-top/--safe-bottom 은 데모 시뮬레이션 값이다.
import { useEffect, useRef } from "react";
import styles from "./SafeArea.module.scss";
type Box<T> = { current: T };
const SAFE_AREA_SCALE = 1.0;
const STEP_MS_TOTAL = 2000;
const MAX_INSET_PX = 24;
export default function SafeArea() {
const wrapRef = useRef(null) as unknown as Box<HTMLDivElement>;
const sliderRef = useRef(null) as unknown as Box<HTMLInputElement>;
const contentRef = useRef(null) as unknown as Box<HTMLDivElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = wrapRef.current;
const slider = sliderRef.current;
const content = contentRef.current;
const readout = readoutRef.current;
if (!wrap || !slider || !content || !readout) return;
const root = document.documentElement;
let isDemo = true;
function apply(t: number) {
const top = Math.round(MAX_INSET_PX * t * SAFE_AREA_SCALE);
const bottom = Math.round(MAX_INSET_PX * t);
root.style.setProperty("--safe-top", top + "px");
root.style.setProperty("--safe-bottom", bottom + "px");
slider!.value = String(Math.round(t * 100));
const cs = getComputedStyle(content!);
readout!.textContent =
"위 " + parseFloat(cs.paddingTop).toFixed(0) + "px · 아래 " + parseFloat(cs.paddingBottom).toFixed(0) + "px";
}
function human() {
if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; }
}
function onInput() { human(); apply(Number(slider!.value) / 100); }
slider.addEventListener("input", onInput);
slider.addEventListener("pointerdown", human);
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo && !reduce) {
const phase = ((ts - start) % STEP_MS_TOTAL) / STEP_MS_TOTAL;
const t = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
apply(t);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
slider!.removeEventListener("input", onInput);
slider!.removeEventListener("pointerdown", human);
cancelAnimationFrame(rafRef.current);
root.style.setProperty("--safe-top", "0px");
root.style.setProperty("--safe-bottom", "0px");
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={wrapRef}>
<div className={styles.frameOuter}>
<div className={styles.notchTop}></div>
<div className={styles.frameInner}>
<div className={styles.content} ref={contentRef}>
<span className={styles.pill}>본문</span>
</div>
</div>
<div className={styles.notchBottom}></div>
</div>
<input
className={styles.slider}
ref={sliderRef}
type="range"
min={0}
max={100}
defaultValue={0}
aria-label="안전 영역 시뮬레이션"
/>
<div className={styles.readout} ref={readoutRef}>위 —px · 아래 —px</div>
</div>
</main>
);
}07 Notch safe-area padding — margin that pushes content clear of a screen's cut-out edges — Measured area 1.788% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Reset: 9 Patterns From Basics to Safe-Area
The post this demo lives in
CSS Reset: 9 Patterns From Basics to Safe-AreaNine css reset patterns: one recalculates padding for the safe area, one shrinks transition duration near zero when moti See all demos in this post
If you like this effect - similar demos
base04 Notch tab — a groove is cut into the selected tab's corner by a notch-depth valueCSS corner-shape: 9 Corner Cutting Patterns
base01 System font stack comparerCSS Typography Playground: 9 Demos, No Webfonts
Landing sections04 Discount badge — a badge that works out its own percentage from list price and sale price9 Pricing Page Design Sections (Copy-Paste)