CSS Safe Area Insets
이런 경험 있으신가요?
여러분도 이런 상황을 겪어본 적 있나요? 완벽하게 만든 모바일 웹사이트를 iPhone에서 열었는데, 헤더가 노치에 가려지거나 하단 버튼이 홈 인디케이터와 겹쳐서 클릭이 안 되는 경우.
저도 최근에 정확히 이 문제를 만났습니다. PWA(Progressive Web App)를 전체 화면 모드로 실행했는데, 고정된 헤더의 뒤로가기 버튼이 노치 영역에 완전히 숨어버렸어요. 사용자가 버튼을 아예 볼 수도 누를 수도 없는 상황이었습니다.
바로 그때 CSS Safe Area Insets가 등장합니다.
Safe Area란 무엇인가요?
Safe Area는 기기의 물리적 제약(노치, 둥근 모서리, 홈 인디케이터)을 피해서 콘텐츠를 안전하게 표시할 수 있는 영역입니다.
왜 필요한가요?
iPhone X 이후로 스마트폰 디자인이 변했습니다.
전통적인 디스플레이 (iPhone 8) 노치가 있는 디스플레이 (iPhone X+)
┌────────────────────┐ ┌────────────────────┐
│ Status Bar │ │ ╔══════╗ Notch │
├────────────────────┤ │ ║ ║ │
│ │ ├─╚══════╝────────────┤
│ │ │ │
│ Content Area │ │ Content Area │
│ (전체 사용 가능) │ │ (일부 제한됨) │
│ │ │ │
│ │ │ │
├────────────────────┤ ├────────────────────┤
│ Home Button │ │ ═══ Home Indicator │
└────────────────────┘ └────────────────────┘
문제점:
- 노치: 상단 중앙이 잘림
- 둥근 모서리: 모서리 콘텐츠가 보이지 않음
- 홈 인디케이터: 하단 버튼과 겹침
- 전체 화면 모드: 문제가 더 심각해짐
/* ❌ 옛날 방식: 고정된 패딩 */
.header {
position: fixed;
top: 0;
padding-top: 20px; /* iPhone 8에서는 OK, iPhone X에서는 노치에 가림 */
}
.footer {
position: fixed;
bottom: 0;
padding-bottom: 20px; /* 홈 인디케이터와 겹칠 수 있음 */
}
/* ✅ 현대적 방식: Safe Area Insets */
.header {
position: fixed;
top: 0;
padding-top: env(safe-area-inset-top); /* 기기에 맞게 자동 조정 */
}
.footer {
position: fixed;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom); /* 홈 인디케이터 피함 */
}
장점:
- 자동 적응: 기기마다 다른 safe area에 자동 대응
- 미래 지향적: 새로운 기기에도 자동 적용
- PWA 필수: 전체 화면 모드에서 필수
- 범용성: iOS뿐 아니라 Android 폴더블에도 사용
기본 사용법
1단계: viewport-fit 설정
먼저 meta 태그를 추가해야 합니다.
<!-- ❌ 기본 viewport (safe area 미사용) -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- ✅ viewport-fit=cover (safe area 사용) -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
viewport-fit 옵션:
auto(기본): 전체 뷰포트가 safe area 내부에 위치contain:auto와 동일cover: 전체 화면을 사용하되, safe area insets 제공
2단계: env() 함수로 insets 사용
CSS에서 env() 함수로 safe area insets를 가져옵니다.
.header {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.footer {
padding-bottom: env(safe-area-inset-bottom);
}
4가지 insets:
safe-area-inset-top: 상단 (노치 영역)safe-area-inset-right: 오른쪽 (가로 모드 노치)safe-area-inset-bottom: 하단 (홈 인디케이터)safe-area-inset-left: 왼쪽 (가로 모드 노치)
3단계: Fallback 값 제공
구형 브라우저를 위한 fallback:
.header {
/* fallback: env()를 지원하지 않는 브라우저용 */
padding-top: 20px;
/* 지원하는 브라우저는 이 값 사용 */
padding-top: env(safe-area-inset-top);
}
/* 또는 max() 사용 */
.header {
/* 최소 20px, safe area가 더 크면 그 값 사용 */
padding-top: max(20px, env(safe-area-inset-top));
}
실전 예제
예제 1: 고정 헤더 (Fixed Header)
시나리오: 상단 고정 헤더가 노치에 가려지지 않게
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
/* 기본 패딩 */
padding: 10px 20px;
/* Safe area 적용 */
padding-top: max(10px, env(safe-area-inset-top));
padding-left: max(20px, env(safe-area-inset-left));
padding-right: max(20px, env(safe-area-inset-right));
background: white;
z-index: 1000;
}
/* 헤더 높이만큼 본문 여백 */
.main-content {
/* 헤더 높이 + safe area */
margin-top: calc(60px + env(safe-area-inset-top));
}
핵심 포인트:
max()로 최소 패딩 보장- 본문은 헤더 높이 + safe area 고려
- 가로 모드도 대응 (left, right)
예제 2: 고정 하단 버튼 (Fixed Bottom Button)
시나리오: “구매하기” 버튼이 홈 인디케이터와 겹치지 않게
.sticky-buy-button {
position: fixed;
bottom: 0;
left: 0;
right: 0;
/* 버튼 자체 패딩 */
padding: 16px 20px;
/* Safe area 적용 */
padding-bottom: max(16px, env(safe-area-inset-bottom));
padding-left: max(20px, env(safe-area-inset-left));
padding-right: max(20px, env(safe-area-inset-right));
background: #007aff;
color: white;
font-size: 18px;
font-weight: bold;
text-align: center;
border: none;
}
/* 버튼 높이만큼 본문 여백 */
.content {
/* 버튼 영역 확보 */
padding-bottom: calc(52px + env(safe-area-inset-bottom));
}
실제 결과:
| 기기 | safe-area-inset-bottom | 최종 padding-bottom |
|---|---|---|
| iPhone 8 | 0px | 16px (기본값) |
| iPhone 14 (세로) | 34px | 34px |
| iPhone 14 (가로) | 21px | 21px |
예제 3: 전체 화면 배경 (Full-Screen Background)
시나리오: 배경은 전체 화면, 콘텐츠는 safe area 내부
.full-screen-container {
/* 전체 화면 사용 */
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* 콘텐츠는 safe area 내부 */
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
}
/* 또는 shorthand */
.full-screen-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* 위 오른쪽 아래 왼쪽 순서 */
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
}
핵심 포인트:
- 배경은 100vh로 전체 화면
- 패딩으로 콘텐츠만 safe area 내부
- 시각적으로 몰입감 유지
예제 4: 하단 탭 네비게이션 (Bottom Tab Navigation)
시나리오: 모바일 앱 스타일 하단 탭
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
background: white;
border-top: 1px solid #e0e0e0;
/* Safe area 적용 */
padding-bottom: env(safe-area-inset-bottom);
}
.bottom-nav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
text-decoration: none;
color: #666;
}
.bottom-nav__item--active {
color: #007aff;
}
.bottom-nav__icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
.bottom-nav__label {
font-size: 10px;
}
/* 본문 여백 */
.page-content {
/* 탭 높이 + safe area */
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
HTML:
<nav class="bottom-nav">
<a href="/" class="bottom-nav__item bottom-nav__item--active">
<svg class="bottom-nav__icon">...</svg>
<span class="bottom-nav__label">홈</span>
</a>
<a href="/search" class="bottom-nav__item">
<svg class="bottom-nav__icon">...</svg>
<span class="bottom-nav__label">검색</span>
</a>
<a href="/profile" class="bottom-nav__item">
<svg class="bottom-nav__icon">...</svg>
<span class="bottom-nav__label">프로필</span>
</a>
</nav>
예제 5: PWA 전체 화면 모드
시나리오: PWA manifest에서 standalone 모드 사용
// manifest.json
{
"name": "My PWA",
"short_name": "PWA",
"display": "standalone", // 전체 화면 모드
"start_url": "/",
"background_color": "#ffffff",
"theme_color": "#007aff"
}
/* 전체 화면 PWA용 레이아웃 */
:root {
/* CSS 변수로 관리 */
--safe-top: env(safe-area-inset-top);
--safe-right: env(safe-area-inset-right);
--safe-bottom: env(safe-area-inset-bottom);
--safe-left: env(safe-area-inset-left);
}
.pwa-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
/* 상태바 영역 피하기 */
padding-top: var(--safe-top);
padding-left: var(--safe-left);
padding-right: var(--safe-right);
background: var(--theme-color);
color: white;
z-index: 100;
}
.pwa-content {
/* 헤더 + safe area 고려 */
padding-top: calc(56px + var(--safe-top));
padding-left: var(--safe-left);
padding-right: var(--safe-right);
/* 하단 탭 + safe area 고려 */
padding-bottom: calc(60px + var(--safe-bottom));
min-height: 100vh;
}
.pwa-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-bottom: var(--safe-bottom);
background: white;
}
핵심 포인트:
- CSS 변수로 재사용성 향상
- 모든 영역에 일관되게 적용
- calc()로 복잡한 계산 처리
고급 기법
1. calc()와 함께 사용
.element {
/* safe area + 추가 여백 */
padding-top: calc(env(safe-area-inset-top) + 20px);
/* safe area + 기존 패딩 */
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
}
2. CSS 변수로 관리
:root {
/* 기본값과 함께 정의 */
--inset-top: env(safe-area-inset-top, 0px);
--inset-bottom: env(safe-area-inset-bottom, 0px);
/* 디자인 시스템과 통합 */
--spacing-top: calc(var(--inset-top) + var(--header-padding));
}
.header {
padding-top: var(--spacing-top);
}
3. max()로 최소값 보장
.element {
/* 최소 20px, safe area가 더 크면 그 값 사용 */
padding-top: max(20px, env(safe-area-inset-top));
/* 최소 16px 보장 */
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
4. clamp()로 범위 제한
.element {
/* 최소 10px, 최대 50px */
padding-top: clamp(
10px,
env(safe-area-inset-top),
50px
);
}
5. 조건부 적용 (@supports)
/* env() 지원 여부 확인 */
@supports (padding: env(safe-area-inset-top)) {
.header {
padding-top: env(safe-area-inset-top);
}
}
/* 또는 */
@supports not (padding: env(safe-area-inset-top)) {
.header {
padding-top: 20px; /* fallback */
}
}
가로 모드 대응
가로 모드에서는 노치가 왼쪽이나 오른쪽에 위치합니다.
.container {
/* 세로 모드: 상단/하단 safe area */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
/* 가로 모드: 좌우 safe area도 필요! */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
가로 모드 시각화:
iPhone 가로 모드 (노치가 왼쪽)
┌─╔══╗─────────────────────────────────┐
│ ║ ║ <-- safe-area-inset-left │
│ ╚══╝ │
│ │
│ Content Area │
│ │
│ ═══ Home Indicator │
└──────────────────────────────────────┘
safe-area-inset-bottom
실전 활용: 완전한 모바일 레이아웃
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Safe Area Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* CSS 변수 정의 */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
/* 디자인 토큰 */
--header-height: 56px;
--bottom-nav-height: 60px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* 고정 헤더 */
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
background: #007aff;
color: white;
display: flex;
align-items: center;
z-index: 100;
/* Safe area 적용 */
padding-top: var(--safe-top);
padding-left: max(16px, var(--safe-left));
padding-right: max(16px, var(--safe-right));
}
.app-header__title {
font-size: 18px;
font-weight: 600;
}
/* 본문 */
.app-content {
/* 상단: 헤더 + safe area */
padding-top: calc(var(--header-height) + var(--safe-top));
/* 좌우: safe area */
padding-left: max(16px, var(--safe-left));
padding-right: max(16px, var(--safe-right));
/* 하단: 탭 네비게이션 + safe area */
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-bottom));
min-height: 100vh;
}
/* 하단 탭 네비게이션 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--bottom-nav-height);
background: white;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: space-around;
/* Safe area 적용 */
padding-bottom: var(--safe-bottom);
padding-left: var(--safe-left);
padding-right: var(--safe-right);
}
.bottom-nav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-decoration: none;
color: #666;
font-size: 10px;
}
.bottom-nav__item--active {
color: #007aff;
}
/* 플로팅 버튼 */
.floating-button {
position: fixed;
right: 16px;
bottom: calc(var(--bottom-nav-height) + var(--safe-bottom) + 16px);
width: 56px;
height: 56px;
border-radius: 50%;
background: #007aff;
color: white;
border: none;
font-size: 24px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
/* 디버그 정보 */
.debug-info {
position: fixed;
top: calc(var(--header-height) + var(--safe-top) + 16px);
left: max(16px, var(--safe-left));
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px;
font-size: 11px;
border-radius: 8px;
font-family: monospace;
}
</style>
</head>
<body>
<header class="app-header">
<h1 class="app-header__title">Safe Area Demo</h1>
</header>
<main class="app-content">
<div class="debug-info">
<div>Top: <span id="top-value">0px</span></div>
<div>Right: <span id="right-value">0px</span></div>
<div>Bottom: <span id="bottom-value">0px</span></div>
<div>Left: <span id="left-value">0px</span></div>
</div>
<!-- 콘텐츠 -->
<p>Lorem ipsum dolor sit amet...</p>
</main>
<nav class="bottom-nav">
<a href="#" class="bottom-nav__item bottom-nav__item--active">
<span>🏠</span>
<span>홈</span>
</a>
<a href="#" class="bottom-nav__item">
<span>🔍</span>
<span>검색</span>
</a>
<a href="#" class="bottom-nav__item">
<span>👤</span>
<span>프로필</span>
</a>
</nav>
<button class="floating-button">+</button>
<script>
// Safe area 값 표시 (디버그용)
function updateSafeAreaValues() {
const root = document.documentElement;
const computedStyle = getComputedStyle(root);
const top = computedStyle.getPropertyValue('--safe-top') || '0px';
const right = computedStyle.getPropertyValue('--safe-right') || '0px';
const bottom = computedStyle.getPropertyValue('--safe-bottom') || '0px';
const left = computedStyle.getPropertyValue('--safe-left') || '0px';
document.getElementById('top-value').textContent = top;
document.getElementById('right-value').textContent = right;
document.getElementById('bottom-value').textContent = bottom;
document.getElementById('left-value').textContent = left;
}
updateSafeAreaValues();
// 화면 회전 시 값 업데이트
window.addEventListener('resize', updateSafeAreaValues);
window.addEventListener('orientationchange', updateSafeAreaValues);
</script>
</body>
</html>
흔한 실수 TOP 5
1. viewport-fit 설정 안 함
<!-- ❌ safe area insets가 0으로 반환됨 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- ✅ viewport-fit=cover 필수 -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
2. 가로 모드 미대응
/* ❌ 상하만 처리 */
.element {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
/* 가로 모드에서 좌우 노치 문제 발생! */
}
/* ✅ 4방향 모두 처리 */
.element {
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
}
3. Fallback 값 누락
/* ❌ 구형 브라우저에서 패딩 없음 */
.element {
padding-top: env(safe-area-inset-top);
}
/* ✅ Fallback 제공 */
.element {
padding-top: 20px; /* fallback */
padding-top: env(safe-area-inset-top);
}
/* ✅ 또는 max() 사용 */
.element {
padding-top: max(20px, env(safe-area-inset-top));
}
4. 100vh 문제 미해결
/* ❌ 100vh는 safe area 미포함 */
.full-height {
height: 100vh;
/* 노치 영역까지 확장됨 */
}
/* ✅ safe area 제외한 높이 */
.full-height {
height: 100vh;
height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
}
/* ✅ 또는 패딩으로 처리 */
.full-height {
min-height: 100vh;
padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
box-sizing: border-box;
}
5. CSS 변수 기본값 누락
/* ❌ env() 미지원 시 변수가 invalid */
:root {
--safe-top: env(safe-area-inset-top);
}
/* ✅ 기본값 제공 */
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
브라우저 지원
| 브라우저 | 버전 | 비고 |
|---|---|---|
| Safari iOS | 11.0+ | 최초 도입 |
| Chrome Android | 69+ | |
| Samsung Internet | 9.2+ | |
| Edge | 79+ | |
| Firefox | ❌ | 미지원 (2025년 기준) |
중요: Firefox는 현재 미지원이므로 반드시 fallback 제공!
테스트 방법
1. iOS 시뮬레이터
# Xcode 설치 후
open -a Simulator
2. Chrome DevTools
- DevTools 열기 (F12)
- Device Toolbar 활성화 (Cmd+Shift+M)
- iPhone X 이상 선택
- “Show device frame” 활성화
3. 실제 기기
- iPhone X 이상에서 테스트
- PWA로 설치 후 전체 화면 모드 확인
- 가로/세로 모드 전환 테스트
실전 체크리스트
Safe Area를 사용할 때 다음을 확인하세요:
- viewport-fit=cover meta 태그 설정
- env() fallback 값 제공 (구형 브라우저용)
- 4방향 모두 처리 (top, right, bottom, left)
- 가로 모드 대응 확인
- max()로 최소값 보장
- PWA 전체 화면 모드 테스트
- 본문 여백 계산 (헤더/푸터 높이 + safe area)
- 실제 기기에서 테스트
마무리
CSS Safe Area Insets는 모던 모바일 웹 개발의 필수 기술입니다.
핵심 요약:
- ✅ viewport-fit=cover: 전체 화면 사용
- ✅ env(): safe area insets 가져오기
- ✅ 4방향: top, right, bottom, left 모두 처리
- ✅ Fallback: 구형 브라우저 대응
- ✅ max(): 최소값 보장
- ⚠️ 주의사항: 가로 모드, 100vh, PWA
다음에 이런 경우를 만나면:
- 노치가 콘텐츠 가림 →
env(safe-area-inset-top) - 홈 인디케이터와 겹침 →
env(safe-area-inset-bottom) - PWA 전체 화면 → 4방향 모두 적용
- 가로 모드 문제 → left, right도 처리
여러분도 Safe Area를 적용해보신 적 있나요? 어떤 기기에서 테스트하셨는지 궁금합니다!
Happy coding! 📱✨
댓글