System Preferences — 사용자 환경을 토큰으로 끌어들이기
이 문서가 답하는 질문: 사용자의 OS·브라우저 설정(모션 감소·고대비·강제 색)을 디자인 시스템 토큰에 어떻게 반영하는가? 한 줄 답 (Pyramid Top): 시스템 환경설정은 테마의 자동 분기 조건이다 —
prefers-reduced-motion,prefers-contrast,forced-colors를 별도 테마 변형이 아닌 기존 semantic 토큰을 덮어쓰는 미디어 쿼리 조건으로 다뤄야 한다.
Why — 왜 존재하는가
다크모드와 멀티 브랜드는 사용자가 명시적으로 선택하는 컨텍스트다. 반면 시스템 환경설정은 사용자가 OS 레벨에서 이미 선언한 접근성 요청이며, 디자인 시스템이 이를 무시하면 다음과 같은 실질적 차별이 일어난다.
| 사용자 | OS 설정 | 디자인 시스템이 무시했을 때 |
|---|---|---|
| 전정 장애·편두통 | ”동작 줄이기” | 모달 슬라이드·spring 애니메이션이 어지럼증 유발 |
| 저시력·고령자 | ”대비 늘리기” | 회색 border·subtle text가 보이지 않음 |
| Windows 고대비 모드 | ”고대비 테마” | 브랜드 색이 강제로 시스템 색으로 치환됨 → 레이아웃 깨짐 |
| 색맹 | (별도 OS 설정 없음 — 디자인이 책임) | 빨강/초록만으로 status 구분하면 정보 손실 |
WCAG 2.1 SC 2.3.3 (Animation from Interactions) 및 1.4.11 (Non-text Contrast)은 위의 처음 두 항목을 AA 레벨 의무로 명시한다.
How — 어떻게 동작하는가
핵심: 시스템 환경설정은 한 단계 위 컨텍스트로 다루지 말고, 토큰 정의 자체를 미디어 쿼리로 분기시킨다.
:root {
--motion-duration-md: 200ms;
--motion-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--color-border-subtle: oklch(0.85 0.01 250);
--color-text-secondary: oklch(0.5 0.01 250);
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration-md: 0ms;
--motion-spring: linear;
}
}
@media (prefers-contrast: more) {
:root {
--color-border-subtle: oklch(0.5 0.01 250);
--color-text-secondary: oklch(0.2 0.01 250);
}
}이렇게 두면 <Button>은 그대로지만, 모션 감소 사용자에겐 즉시 멈추고 고대비 사용자에겐 또렷한 텍스트가 나온다.
What — 구체 사양·수치·예시
3대 시스템 미디어 쿼리
| 쿼리 | 값 | 의미 | 토큰 영향 |
|---|---|---|---|
prefers-reduced-motion | no-preference / reduce | 모션 줄이기 | 모든 --motion-duration-* → 0ms, --motion-spring → linear |
prefers-contrast | no-preference / more / less / custom | 대비 조정 | --color-text-secondary, --color-border-*를 더 진하게 |
forced-colors | none / active | Windows High Contrast | 모든 색이 시스템 색으로 강제 치환됨 |
prefers-color-scheme | light / dark | (6장 참조) | 다크모드 자동 |
prefers-reduced-transparency | no-preference / reduce | 투명도 줄이기 (iOS 17+) | backdrop-filter, opacity 토큰 |
Panda CSS conditions
// panda.config.ts
import { defineConfig } from '@pandacss/dev'
export default defineConfig({
conditions: {
_motionReduce: '@media (prefers-reduced-motion: reduce)',
_contrastMore: '@media (prefers-contrast: more)',
_forcedColors: '@media (forced-colors: active)',
},
tokens: {
durations: {
md: { value: '200ms' },
},
},
})// 컴포넌트에서
import { css } from 'styled-system/css'
const dialog = css({
transition: 'transform var(--durations-md) var(--easings-spring)',
_motionReduce: {
transition: 'none',
},
_contrastMore: {
borderColor: 'border.strong',
borderWidth: '2px',
},
})Tailwind v4 variants
<button class="
transition duration-200
motion-reduce:transition-none
contrast-more:border-2
contrast-more:border-foreground
forced-colors:bg-[Canvas]
forced-colors:text-[CanvasText]
">
Action
</button>forced-colors — 시스템 색 키워드
Windows High Contrast 모드가 활성화되면 브라우저가 모든 색을 무시하고 7개의 시스템 색 키워드만 사용한다.
| 키워드 | 의미 |
|---|---|
Canvas | 페이지 배경 |
CanvasText | 본문 텍스트 |
LinkText | 링크 |
VisitedText | 방문한 링크 |
ButtonFace | 버튼 배경 |
ButtonText | 버튼 텍스트 |
Highlight | 선택 영역 배경 |
HighlightText | 선택 영역 텍스트 |
GrayText | 비활성 텍스트 |
@media (forced-colors: active) {
.button-primary {
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonText;
forced-color-adjust: none; /* 의도적으로 시스템 색 무시할 때만 */
}
}forced-color-adjust: none은 브랜드 일관성 욕심 때문에 남용하기 쉬운 함정이다. 로고처럼 반드시 브랜드 색이어야 하는 곳에만 사용한다.
prefers-reduced-motion — 단순 미디어쿼리 이상
CSS 뿐 아니라 JS 모션 라이브러리도 같은 신호를 읽어야 한다.
// Framer Motion / Motion One
import { useReducedMotion } from 'motion/react'
function Dialog() {
const reduced = useReducedMotion()
return (
<motion.div
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.2 }}
/>
)
}CSS만 처리하고 JS 애니메이션을 그대로 두면 모달이 여전히 뛰어다닌다.
What-if — 잘못 쓰면 어떻게 깨지는가
- 모션 토큰을 0ms로 바꿨는데 JS 트윈이 그대로: 컴포넌트가 일부만 멈춤 → 사용자가 부분 변화에 더 혼란. JS 라이브러리도
prefers-reduced-motion을 읽도록 통일. forced-color-adjust: none을 컴포넌트 전체에 박음: Windows 고대비 사용자가 디자인 시스템 전체를 못 읽게 됨. 로고·일러스트 등 회사 정체성 영역에만.prefers-contrast: more를 단순히 글자색을 검정으로: 다크모드와 충돌해 다크 + 고대비에서 흰 배경에 검은 글자가 나옴. 조건을 AND로 명시 (@media (prefers-color-scheme: dark) and (prefers-contrast: more)).- 테마 토글 UI에 “모션 줄이기”를 또 만듦: OS 설정과 별도라 사용자가 둘 다 켜야 함. OS 설정을 기본값으로 받고, 사이트 토글은 override로 (3-state:
system/on/off). - JS에서
window.matchMedia한 번만 읽음: 사용자가 도중에 OS 설정을 바꾸면 반영 안 됨.addEventListener('change', ...)구독 필수.
Insight — 흥미로운 이야기
prefers-reduced-motion은 2017년 macOS Sierra의 “Reduce motion” 토글이 부각된 뒤 W3C Media Queries Level 5 초안에 들어갔고, Safari 10.1(2017)에서 가장 먼저 지원됐다. 한참 뒤인 2019년 Chrome 74에 들어왔는데, 그 사이 2년간 React Spring·Framer Motion 같은 라이브러리들은 직접 매체 쿼리를 읽어 폴리필하는 코드를 만들었다.
forced-colors는 흥미로운 역사가 있다. Windows의 High Contrast 모드는 1990년대 후반부터 존재했고 IE는 이를 모든 색을 무조건 시스템 색으로 덮는 CSS extension -ms-high-contrast로 지원했다. 표준화된 W3C forced-colors 미디어 쿼리는 Edge Chromium 전환(2020) 이후에야 표준 경로가 열렸다. 그래서 지금도 일부 레거시 코드에선 -ms-high-contrast: active와 forced-colors: active 두 쿼리를 동시에 쓴다.
가장 중요한 통찰: 시스템 환경설정은 “특수 모드”가 아니라 “기본 상태에 가까운 사용자 다수”의 요청이다. macOS·iOS 통계에서 “Reduce Motion”을 켠 사용자는 5~10%로 추정된다 — 다크모드 사용률에 비교해 작지 않다.
요약
- 시스템 환경설정은 컨텍스트 추가가 아니라 토큰 정의의 미디어 쿼리 분기다.
- 3대 쿼리:
prefers-reduced-motion,prefers-contrast,forced-colors. 각각 모션 토큰·색 토큰을 자동 덮어쓴다. - Panda는
conditions, Tailwind는motion-reduce:/contrast-more:/forced-colors:variants로 노출한다. - JS 애니메이션도
useReducedMotion같은 훅으로 같은 신호를 구독해야 한다.