🧩 Design System6. Theming (다크·브랜드·a11y)System Preferences — 사용자 환경을 토큰으로 끌어들이기

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-motionno-preference / reduce모션 줄이기모든 --motion-duration-*0ms, --motion-springlinear
prefers-contrastno-preference / more / less / custom대비 조정--color-text-secondary, --color-border-*를 더 진하게
forced-colorsnone / activeWindows High Contrast모든 색이 시스템 색으로 강제 치환됨
prefers-color-schemelight / dark(6장 참조)다크모드 자동
prefers-reduced-transparencyno-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: activeforced-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 같은 훅으로 같은 신호를 구독해야 한다.