Skip to Content
WebCSS모던 CSS18. prefers-* 대응과 완료 점검

이번 편의 결과물: 다크모드·뷰 전환·스크롤 연동 애니메이션이 전부 prefers-reduced-motion: reduce에서 꺼지고, prefers-contrast: more에서 테두리·글자 대비가 강해집니다. css/tokens.csscss/styles.css 정본 전체를 다시 실어 이 과목에서 만든 @supports 폴백을 한 번에 점검합니다. 다루는 개념: prefers-reduced-motion/prefers-color-scheme/prefers-contrast, @supports 폴백 총정리, 01_학습방향.md 완료 기준 5가지 자가 점검

이 편에서 만드는 파일

web-practice/portfolio/ └── css/ ├── tokens.css ~ (prefers-contrast 대응 토큰 추가 — 정본 전체 수록) └── styles.css ~ (prefers-reduced-motion 총정리 규칙 추가 — 정본 전체 수록) web-practice/lessons/modern-css/ └── 18_prefers-media-and-fallback-checklist/ └── checklist.md + (완료 기준 5가지 자가 점검표)

개념 정리

prefers-* 미디어 기능은 운영체제 설정을 CSS에서 읽는 방법입니다. 지금까지 편에서 색·모션 기능을 만들 때마다 이 값을 하나씩 썼는데, 이번 편에서 셋을 한 파일에 모아 사이트 전체에 일관되게 적용합니다.

미디어 기능의미
prefers-reduced-motionreduce / no-preference시스템에서 모션 최소화를 켰는지
prefers-color-schemelight / dark시스템 색상 모드(13편의 light-dark()가 기본값으로 참조)
prefers-contrastmore / less / no-preference시스템에서 고대비 모드를 켰는지

세 기능 모두 널리 사용 가능(Baseline widely available)이라 폴백 없이 바로 써도 됩니다. 다만 이 기능들이 반응하는 대상(애니메이션, 색 토큰)은 이 과목 전체에서 새로 만든 것이므로, 한 곳에 모아 놓지 않으면 어떤 애니메이션이 빠졌는지 놓치기 쉽습니다.

prefers-reduced-motion: reduce에서 모든 애니메이션·전환을 끄는 표준 패턴은 다음과 같습니다.

접근장점단점
animation-duration: 0.01ms !important 방식애니메이션이 즉시 끝난 상태로 남아 최종 레이아웃이 깨지지 않음!important 사용, 규칙이 길어짐
animation: none !important 방식코드가 짧음애니메이션이 from 상태에 멈춰 레이아웃이 어색할 수 있음

이 과목은 첫 번째 방식을 채택합니다. 애니메이션이 0에 수렴하는 속도로 끝나 to 상태(최종 모습)에 바로 도달하기 때문입니다.

실습

1. 완료 기준 점검표 만들기

web-practice/lessons/modern-css/18_prefers-media-and-fallback-checklist/checklist.md를 만들어 01_학습방향.md의 완료 기준 5가지를 그대로 옮겨 적습니다.

# web-practice/lessons/modern-css/18_prefers-media-and-fallback-checklist/checklist.md [ ] 1. @layer(reset·tokens·base·components·utilities)로 재정리, 순서 바꿔 우선순위 뒤집기 확인 [ ] 2. 컨테이너 쿼리+서브그리드 카드 그리드, @scope로 스타일 격리 확인 [ ] 3. oklch·color-mix()·@property·light-dark() 조합 다크모드 토글 확인 [ ] 4. 뷰 전환·스크롤 연동 애니메이션 + 각각 @supports 폴백 확인 [ ] 5. 앵커 포지셔닝 툴팁 + prefers-reduced-motion/prefers-color-scheme 반응 확인

2. css/tokens.css 정본 전체 작성하기

이 과목에서 다룬 커스텀 속성 토큰(10편)·@property(11편)·oklch/color-mix()(12편)·light-dark()(13편)·clamp()(14편)·prefers-contrast 대응을 모두 포함한 최종 상태입니다.

/* portfolio/css/tokens.css — modern_css 18편 시점 정본 */ @property --gradient-angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; } :root { color-scheme: light dark; /* 색 원본(oklch) — 11·12편 */ --hue-primary: 250; --hue-accent: 350; --oklch-primary: oklch(55% 0.18 var(--hue-primary)); --oklch-accent: oklch(55% 0.18 var(--hue-accent)); /* 색 토큰 — light-dark()로 두 모드 동시 지정, 13편 */ --color-bg: light-dark(oklch(100% 0 0), oklch(20% 0.02 var(--hue-primary))); --color-surface: light-dark(oklch(97% 0.005 var(--hue-primary)), oklch(26% 0.02 var(--hue-primary))); --color-text: light-dark(oklch(22% 0.01 var(--hue-primary)), oklch(93% 0.01 var(--hue-primary))); --color-text-muted: light-dark(oklch(46% 0.02 var(--hue-primary)), oklch(70% 0.02 var(--hue-primary))); --color-primary: light-dark(var(--oklch-primary), oklch(75% 0.15 var(--hue-primary))); --color-primary-hover: light-dark(oklch(45% 0.18 var(--hue-primary)), oklch(80% 0.15 var(--hue-primary))); --color-border: light-dark(oklch(85% 0.01 var(--hue-primary)), oklch(35% 0.02 var(--hue-primary))); --color-accent: light-dark(var(--oklch-accent), oklch(75% 0.15 var(--hue-accent))); /* 파생 색 — color-mix()로 자동 계산, 12편 */ --color-surface-hover: color-mix(in oklch, var(--color-surface), var(--color-text) 6%); --color-primary-muted: color-mix(in oklch, var(--color-primary), transparent 85%); --color-border-strong: color-mix(in oklch, var(--color-border), var(--color-text) 30%); /* 타이포 — clamp() 유동 값, 14편 */ --font-sans: "Pretendard", "Noto Sans KR", system-ui, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, monospace; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: clamp(1.125rem, 1.05rem + 0.4vw, 1.25rem); --text-xl: clamp(1.25rem, 1.1rem + 0.7vw, 1.5rem); --text-2xl: clamp(1.5rem, 1.2rem + 1.2vw, 2rem); --text-3xl: clamp(2rem, 1.4rem + 2.4vw, 3rem); --leading: 1.6; /* 간격(4px 배수) — css_fundamentals에서 이어받음, 09편에서 논리 속성으로만 소비 */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem; --space-12: 3rem; --space-16: 4rem; /* 레이아웃 */ --container-max: 72rem; --radius: 0.5rem; --shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 4px 12px rgb(0 0 0 / 0.06); --header-height: 4rem; /* 타입 지정된 애니메이션 커스텀 속성 — 11편 */ --gradient-angle: 0deg; } /* prefers-contrast 대응 — 18편 */ @media (prefers-contrast: more) { :root { --color-border: light-dark(oklch(35% 0.02 var(--hue-primary)), oklch(80% 0.02 var(--hue-primary))); --color-border-strong: light-dark(oklch(15% 0.01 var(--hue-primary)), oklch(95% 0.01 var(--hue-primary))); --color-text-muted: light-dark(oklch(28% 0.02 var(--hue-primary)), oklch(88% 0.02 var(--hue-primary))); } } /* 사용자가 라이트/다크를 직접 고른 경우 — 13편 토글 버튼이 이 속성을 바꾼다 */ :root[data-theme='light'] { color-scheme: light; } :root[data-theme='dark'] { color-scheme: dark; }

3. css/styles.css 정본 전체 작성하기

@layer 5개(03편)에 중첩(04편)·:has()(05편)·컨테이너 쿼리(06편)·@scope(07편)·서브그리드(08편)·논리 속성(09편)·뷰 전환(15편)·스크롤 연동 애니메이션(16편)·앵커 포지셔닝(17편)까지 전부 담긴 최종본입니다.

/* portfolio/css/styles.css — modern_css 18편 시점 정본 */ @layer reset, tokens, base, components, utilities; @layer reset { *, *::before, *::after { box-sizing: border-box; margin: 0; } img, svg { max-inline-size: 100%; block-size: auto; display: block; } button, input, select, textarea { font: inherit; color: inherit; } } @layer base { body { font-family: var(--font-sans); background: var(--color-bg); color: var(--color-text); line-height: var(--leading); } a { color: var(--color-primary); text-decoration: none; } a:hover { color: var(--color-primary-hover); } a.logo, .site-nav a, .project-card a { color: inherit; } .visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); } } @layer components { /* 헤더 & 내비게이션 — <nav class="site-nav">/<nav id="mobile-menu" popover> 안에는 ul·li 없이 <a> 3개가 바로 들어 있다 */ .site-header { position: sticky; inset-block-start: 0; block-size: var(--header-height); border-block-end: 1px solid var(--color-border); background: var(--color-bg); display: flex; align-items: center; justify-content: space-between; padding-inline: var(--space-6); & .logo { font-size: var(--text-lg); font-weight: 700; } } .site-nav { display: none; gap: var(--space-6); } .site-nav a[aria-current='page'] { color: var(--color-primary); font-weight: 700; } .menu-toggle { display: inline-flex; align-items: center; justify-content: center; inline-size: 2.5rem; block-size: 2.5rem; border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-bg); } #mobile-menu { display: flex; flex-direction: column; gap: var(--space-4); position: fixed; inset-block-start: var(--header-height); inset-inline: var(--space-4); margin: 0; padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-bg); box-shadow: var(--shadow); } @media (min-width: 1024px) { .site-nav { display: flex; } .menu-toggle { display: none; } } /* 버튼 */ .button { display: inline-block; border-radius: var(--radius); padding: var(--space-2) var(--space-6); font-weight: 700; background-color: var(--color-primary); color: var(--color-bg); border: 1px solid transparent; transition: background-color 200ms ease, border-color 200ms ease, transform 100ms ease; } .button:hover { background-color: var(--color-primary-hover); } .button-secondary { background-color: transparent; border-color: var(--color-border); color: var(--color-text); } /* 히어로 — 01편 등장 애니메이션 + 11편 @property 그라디언트 */ .hero { padding-block: var(--space-16); text-align: center; background-image: conic-gradient(from var(--gradient-angle), var(--color-primary-muted), var(--color-surface)); transition: --gradient-angle 400ms ease; } .hero:hover { --gradient-angle: 45deg; } .hero h1 { font-size: var(--text-3xl); } .hero p { font-size: var(--text-lg); color: var(--color-text-muted); } .hero > * { animation: hero-enter 600ms ease-out both; } .hero > *:nth-child(2) { animation-delay: 150ms; } .hero > *:nth-child(3) { animation-delay: 300ms; } .hero > *:nth-child(4) { animation-delay: 400ms; } @keyframes hero-enter { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } /* 소개 / 기술 스택 / 경력 */ .about, .skills, .career { padding-block: var(--space-12); } .about { display: flex; flex-wrap: wrap; gap: var(--space-8); } .about img { inline-size: 12rem; border-radius: var(--radius); } .skills ul { display: flex; flex-wrap: wrap; gap: var(--space-4); } .skills li { padding: var(--space-2) var(--space-4); background: var(--color-surface); border-radius: var(--radius); font-size: var(--text-sm); } .career table { inline-size: 100%; border-collapse: collapse; } .career th, .career td { padding: var(--space-3) var(--space-4); border-block-end: 1px solid var(--color-border); text-align: start; } .career th { background: var(--color-surface); } /* 폼 상태 — 05편 :has() */ .contact-form:has(:invalid) button[type='submit'] { background: var(--color-border-strong); cursor: not-allowed; } .contact-form:not(:has(:invalid)) button[type='submit'] { background: var(--color-primary); } .contact-form:has(:invalid:focus) { outline: 2px dashed var(--color-accent); outline-offset: var(--space-2); } /* 프로젝트 카드 그리드 — 06편 컨테이너 쿼리 + 08편 서브그리드 */ .project-grid { container-type: inline-size; container-name: project-grid; display: grid; grid-template-columns: 1fr; grid-template-rows: auto auto auto auto; gap: var(--space-6); } @container project-grid (min-width: 30rem) { .project-grid { grid-template-columns: repeat(2, 1fr); } } @container project-grid (min-width: 48rem) { .project-grid { grid-template-columns: repeat(3, 1fr); } } .project-grid:has(> :only-child) { grid-template-columns: 1fr; place-items: center; } /* 빈 카드 그리드 안내 문구 — 05편 :has() */ .project-grid:has(.project-card) .project-grid__empty { display: none; } .project-grid__empty { padding: var(--space-12); text-align: center; color: var(--color-text-muted); } /* 카드 스타일 격리 — 07편 @scope, 한계는 button[data-open-project] */ @scope (.project-card) to (button[data-open-project]) { :scope { display: grid; grid-template-rows: subgrid; grid-row: span 4; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); overflow: hidden; transition: transform 200ms ease, box-shadow 200ms ease; } :scope:hover { transform: translateY(-2px); box-shadow: var(--shadow); } img { aspect-ratio: 3 / 2; object-fit: cover; } h2 { font-size: var(--text-lg); padding-inline: var(--space-4); margin-block-start: var(--space-4); } p { padding-inline: var(--space-4); color: var(--color-text-muted); font-size: var(--text-sm); } .tags { display: flex; gap: var(--space-2); flex-wrap: wrap; padding-inline: var(--space-4); } .tags li { padding: var(--space-1) var(--space-3); background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 999px; font-size: var(--text-xs); } } .project-card button[data-open-project] { margin: var(--space-4); margin-block-start: auto; } /* 프로젝트 상세 모달 */ #project-dialog { inline-size: min(32rem, 90vw); border: none; border-radius: var(--radius); padding: var(--space-8); background: var(--color-bg); box-shadow: var(--shadow); } #project-dialog::backdrop { background: rgb(15 23 32 / 0.5); } /* 연락 폼 — 필드 래퍼 .field, 체크박스 줄 .field-checkbox, 도움말 .help */ .contact-form { display: flex; flex-direction: column; gap: var(--space-6); max-inline-size: 32rem; } .field { display: flex; flex-direction: column; gap: var(--space-2); } .field label { font-size: var(--text-sm); font-weight: 700; } .field input, .field select, .field textarea { inline-size: 100%; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius); } .field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-muted); } .field input:invalid:not(:placeholder-shown) { border-color: var(--color-accent); } .field-checkbox { flex-direction: row; align-items: center; gap: var(--space-2); } .field .help { font-size: var(--text-xs); color: var(--color-text-muted); } .contact-form button[type='submit'] { block-size: 2.75rem; padding-inline: var(--space-6); border-radius: var(--radius); font-weight: 700; background: var(--color-primary); color: var(--color-bg); border: 1px solid transparent; } /* FAQ */ .faq details { border-block-end: 1px solid var(--color-border); padding-block: var(--space-4); } .faq summary { display: flex; justify-content: space-between; cursor: pointer; font-weight: 700; } /* 앵커 포지셔닝 툴팁 — 17편 */ .info-trigger { display: inline-grid; place-items: center; inline-size: 1.25rem; block-size: 1.25rem; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text-muted); font-size: var(--text-xs); cursor: help; } .card-tooltip { position: fixed; top: calc(anchor(bottom) + var(--space-2)); left: anchor(center); translate: -50% 0; position-try-fallbacks: flip-block; inline-size: max-content; max-inline-size: 14rem; padding: var(--space-2) var(--space-3); border-radius: var(--radius); background: var(--color-text); color: var(--color-bg); font-size: var(--text-xs); opacity: 0; visibility: hidden; transition: opacity 150ms ease; } .info-trigger:hover + .card-tooltip, .info-trigger:focus-visible + .card-tooltip { opacity: 1; visibility: visible; } @supports not (anchor-name: --info-1) { .project-card { position: relative; } .card-tooltip { position: absolute; top: auto; bottom: calc(100% + var(--space-2)); left: var(--space-4); translate: none; } } /* 다크모드 토글 버튼 — 13편 */ #theme-toggle { margin-inline-start: auto; inline-size: 2.5rem; block-size: 2.5rem; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-surface); font-size: var(--text-lg); cursor: pointer; } #theme-toggle:hover { background: var(--color-surface-hover); } /* 푸터 */ .site-footer { padding-block: var(--space-8); border-block-start: 1px solid var(--color-border); color: var(--color-text-muted); font-size: var(--text-sm); } } @layer utilities { .container { max-inline-size: var(--container-max); margin-inline: auto; padding-inline: var(--space-6); } } /* 뷰 전환 — 15편, projects.html 필터 탭 전환에 적용 */ ::view-transition-old(project-grid), ::view-transition-new(project-grid) { animation-duration: 250ms; } @supports not (view-transition-name: none) { .project-card { transition: opacity 200ms ease; } } /* 스크롤 연동 애니메이션 — 16편, 미지원 시 즉시 보임 */ .project-card { opacity: 1; } @supports (animation-timeline: view()) { .project-card { opacity: 0; animation: card-reveal linear both; animation-timeline: view(); animation-range: entry 0% cover 30%; } @keyframes card-reveal { from { opacity: 0; transform: translateY(1rem); } to { opacity: 1; transform: none; } } } /* prefers-* 총정리 — 18편, 이 과목에서 만든 모든 모션이 대상 */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } .hero:hover { --gradient-angle: 0deg; } .project-card { opacity: 1; } }

4. 실행

VS Code에서 portfolio/index.html · projects.html · contact.html을 브라우저로 엽니다. 운영체제 설정에서 "동작 줄이기(reduce motion)"를 켜고 새로고침해 차이를 확인합니다. - macOS: 시스템 설정 → 손쉬운 사용 → 디스플레이 → 동작 줄이기 - Windows: 설정 → 접근성 → 시각 효과 → 애니메이션 효과 끄기

5. 확인

  • 동작 줄이기를 켜면 카드 hover 이동, 히어로 그라디언트 회전, 스크롤 등장 애니메이션이 전부 즉시 멈춘 상태로 보인다(깜빡이거나 어색하게 멈추지 않는다)
  • 시스템 다크모드를 켜면 토글 버튼을 누르지 않아도 light-dark() 기본값이 다크 팔레트로 바뀐다
  • 시스템 고대비 모드를 켜면 카드·폼 테두리가 눈에 띄게 진해진다
  • checklist.md의 5개 항목을 실제로 사이트에서 재현해 전부 체크할 수 있다

직접 해보기

  1. prefers-reduced-motion: reduce 블록에 .hero:hover { --gradient-angle: 0deg; }가 왜 별도로 필요한지, 이 줄을 지우면 무엇이 달라지는지 확인해 보세요.

답 보기

transition-duration: 0.01ms !important전환 시간만 0에 가깝게 줄일 뿐, :hover에서 --gradient-angle45deg로 바뀌는 것 자체는 막지 않습니다. 즉 회전은 일어나지만 아주 빠르게 끝날 뿐이라 여전히 “움직임”으로 인지될 수 있습니다. .hero:hover { --gradient-angle: 0deg; }로 값 자체를 고정해야 hover해도 그라디언트가 전혀 돌지 않습니다.

자주 하는 실수

증상원인고치는 법
동작 줄이기를 켜도 카드가 스크롤 시 여전히 나타났다 사라진다@supports (animation-timeline: view()) 안 애니메이션을 prefers-reduced-motion 규칙보다 CSS 파일 뒤쪽에 둬 우선순위가 밀렸다prefers-reduced-motion 블록을 파일 가장 마지막에 두거나 명시도를 확인
고대비 모드에서 테두리 색이 안 바뀐다--color-border@layer components에서 다시 하드코딩해 토큰을 덮어썼다컴포넌트에서 항상 커스텀 속성(var(--color-border))만 참조하고 직접 색상값을 쓰지 않는다
다크모드 시스템 설정을 꺼도 사이트가 계속 다크로 보인다data-theme="dark"가 localStorage에 남아 있는데 토글 버튼 초기화 로직이 없다토글 버튼에 “시스템 설정 따르기” 옵션을 추가하거나 data-theme 속성을 지우는 버튼 제공

확인 문제

문제 14지선다
prefers-reduced-motion: reduce에서 애니메이션을 끌 때 animation-duration: 0.01ms 방식을 animation: none 방식보다 선호하는 이유는?
문제 24지선다
이 과목에서 light-dark()가 참조하는 기본 모드를 결정하는 것은?
문제 34지선다
18편의 완료 점검에서 뷰 전환과 스크롤 연동 애니메이션에 공통으로 요구되는 것은?
문제 44지선다
prefers-contrast: more에서 --color-border 값만 바꾸고 --color-border-strong은 그대로 두면 생기는 문제는?

참고 자료

Last updated on