Open to Work

[번역] 다크 모드를 넘어서: CSS 필터로 사용자가 UI를 직접 조정하게 하기

원저자의 허락 하에 Beyond Dark Mode: Let Users Tune Your UI with CSS Filters를 한국어로 번역한 글입니다.

오늘날 대부분의 웹 앱은 사용자에게 라이트 모드 또는 다크 모드라는 이분법적인 테마 선택지만 제공합니다. 하지만 시각적 접근성은 이분법적이지 않습니다. 어떤 사람은 밤에 화면이 약간만 더 어두워야 하고, 어떤 사람은 더 높은 대비를 필요로 하며, 또 어떤 사람은 따뜻한 색조가 읽기 편하다고 느낍니다. 개발자나 디자이너가 수십 개의 테마를 직접 만드는 수고를 하는 대신, CSS 필터를 사용해 사용자가 인터페이스를 직접 미세 조정할 수 있도록 할 수 있습니다.

그런데 잠깐, 이런 의문이 들 수도 있습니다. 시각적 접근성이 필요한 사용자라면, OS 수준에서 화면을 조정하는 소프트웨어를 이미 사용하고 있지 않을까요? 글쎄요, 아마 그럴 수도 있지만, 우리가 그걸 알 수 있을까요? 공용 컴퓨터나 게스트 컴퓨터를 사용하고 있거나, 스마트폰을 쓰고 있다면 어떨까요? 우리는 사용자의 상황을 다 알 수 없으므로, 더 많은 선택지를 제공하는 것이 거의 항상 더 좋습니다!

소개

이 글은 범위 슬라이더backdrop-filter를 사용하여 사용자가 직접 조절 가능한 간단한 시스템을 구축하는 단계별 튜토리얼입니다. 설정은 로컬스토리지에 저장되어 세션이 바뀌어도 유지됩니다.

구현은 다음 두 부분으로 진행됩니다.

  1. 전체 UI에 backdrop-filter 오버레이 적용하기 (포인터 이벤트 통과 방식).
  2. 사용자가 밝기, 대비, 채도, 색조를 조정할 수 있는 슬라이더 추가하기.

백드롭 오버레이

핵심 기술은 backdrop-filter가 적용된 전체 화면 오버레이를 추가하는 것입니다. 이 오버레이는 뒤에 있는 모든 요소에 필터를 적용하면서, 동시에 모든 포인터 이벤트(클릭, 호버 등)는 그대로 통과시킵니다.

왜 루트 HTML이나 BODY 요소에 직접 필터를 적용하지 않을까요? 물론 그렇게 할 수도 있지만, 오버레이 요소를 사용하면 필터 조절 컨트롤을 그 위에 띄울 수 있습니다. 또한, 제 경험상 루트 요소에 필터를 추가하면 몇 가지 이상한 부작용(고정 스크롤의 오작동, 루트 배경색 미포함 등)이 발생하곤 합니다. 긴 페이지에서는 화면 밖 콘텐츠를 포함한 전체 페이지에 필터가 적용되기 때문에 성능 문제도 있습니다. 많은 연구와 테스트를 거친 후, 화면 크기에 맞춘 고정된 오버레이 DIV가 이를 구현하는 가장 좋은 방법이라는 결론을 내렸습니다.

성능이 걱정되시나요? 걱정하실 필요 없습니다. 요즘 필터는 전적으로 GPU에서 렌더링되며, 2019년형 인텔 맥북 프로에서 테스트했을 때 오버레이에 여러 필터를 적용해도 60FPS 부드러운 스크롤이 가능했습니다. 물론, 웹 앱 자체에 이미 많은 양의 복잡한 애니메이션이나 필터가 있다면 약간의 지연이 발생할 수 있습니다. 하지만 대부분의 웹 앱에서 성능 영향은 무시할 수 있는 수준입니다.

1단계: HTML 마크업

쉬운 부분부터 시작합시다. 모든 것 위에 떠 있는 단일 "영구적인" 요소를 HTML에 추가해야 합니다. 이것은 BODY 태그 바로 안쪽에 넣으세요. 필요할 때까지는 완전히 숨겨둘 것이니 걱정 마세요. 스크린 리더가 무시할 수 있도록 aria-hidden도 추가합니다.

<div id="filter-overlay" aria-hidden="true"></div>

2단계: 초기 CSS 스타일

이제 오버레이에 초기 스타일을 추가합시다. 전체 뷰포트를 덮는 크기로 설정하고, 모든 것 위에 떠서 함께 스크롤되고, 모든 포인터 이벤트를 그대로 통과시킵니다.

#filter-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  pointer-events: none;
  backdrop-filter: none;
  will-change: backdrop-filter;
  display: none;
}

이 CSS 속성들을 설명하겠습니다:

CSS 속성설명
position: fixedDIV를 제자리에 고정하여 페이지 크기에 상관없이 움직이거나 스크롤되지 않게 합니다.
inset: 0left, top, right, bottom을 같은 값으로 설정하는 단축 속성입니다. 모두 0으로 설정하면 DIV가 전체 화면을 차지합니다.
z-index: 1000앱의 다른 모든 요소 위에 DIV를 띄웁니다. 앱의 상황에 맞게 조정하세요.
pointer-events: none호버와 클릭을 포함한 모든 포인터(마우스) 이벤트가 오버레이를 그대로 통과하게 합니다.
backdrop-filter: none필터 없이 시작합니다. 나중에 필요에 따라 동적으로 추가할 것입니다.
will-change: backdrop-filter시간이 지남에 따라 backdrop-filter 속성을 변경할 것이라는 힌트를 브라우저에 제공합니다. 더 읽기.
display: none완전히 숨겨진 상태로 시작합니다.

이 시점에서는 아무것도 보이지 않지만, 오버레이는 사용할 준비가 되어 있습니다. 다음으로 필터를 적용하는 방법을 알아보겠습니다.

3단계: 필터 적용하기

오버레이 요소에 현재 필터를 적용하는 함수를 작성하는 것부터 시작합시다. 함수에 filters라는 미리 정의된 객체가 전달된다고 가정합니다.

const overlay = document.getElementById("filter-overlay");
 
function applyFilters(filters) {
  const css = [
    filters.brightness !== 100 ? `brightness(${filters.brightness}%)` : "",
    filters.contrast !== 100 ? `contrast(${filters.contrast}%)` : "",
    filters.hue !== 0 ? `hue-rotate(${filters.hue}deg)` : "",
    filters.saturate !== 100 ? `saturate(${filters.saturate}%)` : "",
    filters.sepia !== 0 ? `sepia(${filters.sepia}%)` : "",
    filters.grayscale !== 0 ? `grayscale(${filters.grayscale}%)` : "",
    filters.invert !== 0 ? `invert(${filters.invert}%)` : "",
  ]
    .join(" ")
    .trim();
 
  overlay.style.display = css ? "block" : "none";
  overlay.style.backdropFilter = css || "none";
}

여기서의 아이디어는 기본값에서 변경된 필터들만 모아 backdrop-filter 속성값을 위한 CSS 문자열을 만듭니다. 만약 모든 설정이 기본값이라면, 필터를 비활성화하고 요소 전체를 숨깁니다 (즉, 기능을 사용하지 않을 때 오버헤드가 없습니다).

4단계: 사용자 제어 컨트롤 추가

이제 슬라이더가 있는 작은 제어 패널을 추가합시다. 먼저 HTML 마크업입니다.

<section id="visual-controls" role="region" aria-label="Visual preferences">
  <h2>Visual Preferences</h2>
 
  <label for="vf-brightness">Brightness</label>
  <input type="range" id="vf-brightness" min="25" max="200" value="100" />
 
  <label for="vf-contrast">Contrast</label>
  <input type="range" id="vf-contrast" min="25" max="200" value="100" />
 
  <label for="vf-hue">Hue</label>
  <input type="range" id="vf-hue" min="-180" max="180" value="0" />
 
  <label for="vf-saturate">Saturation</label>
  <input type="range" id="vf-saturate" min="0" max="200" value="100" />
 
  <label for="vf-sepia">Sepia</label>
  <input type="range" id="vf-sepia" min="0" max="100" value="0" />
 
  <label for="vf-grayscale">Grayscale</label>
  <input type="range" id="vf-grayscale" min="0" max="100" value="0" />
 
  <label for="vf-invert">Invert</label>
  <input type="range" id="vf-invert" min="0" max="100" value="0" />
 
  <button id="vf-reset" type="button">Reset</button>
</section>

그리고 컨트롤을 위한 간단한 CSS 스타일링입니다.

#visual-controls {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  padding: 1rem;
  border-radius: 12px;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  z-index: 1001; /* 오버레이보다 위에 위치 */
}
 
#visual-controls label {
  display: block;
  margin-top: 0.5rem;
}
#visual-controls input[type="range"] {
  width: 100%;
}

여기서 중요한 점은 z-index를 사용하여 컨트롤을 오버레이 필터 위에 띄우는 것입니다. 이렇게 하는 이유는 사용자가 실수로 슬라이더를 조정하여 UI가 보이지 않게 될 수 있기 때문입니다. 우리는 사용자가 항상 원래 설정을 복원할 수 있도록 해야 합니다.

5단계: 모든 요소 연결하기

이제 간단한 자바스크립트로 모든 것을 연결합시다. 먼저, 페이지 로드 시 실행할 초기화 코드입니다. 이전에 저장된 값이 있으면 가져오고, 현재 필터로 적용합니다.

let state = {
  brightness: 100,
  contrast: 100,
  hue: 0,
  saturate: 100,
  sepia: 0,
  grayscale: 0,
  invert: 0,
};
 
// 저장된 값을 로드하고 기본 상태와 병합
try {
  const saved = JSON.parse(localStorage.getItem("filters"));
  if (saved) state = { ...state, ...saved };
} catch (e) {}
 
// 필터 즉시 적용
applyFilters(state);

다음으로 사용자가 범위 슬라이더를 조작할 때를 처리하는 코드입니다.

// 슬라이더 연결
Object.keys(state).forEach((key) => {
  const el = document.getElementById("vf-" + key);
  el.value = state[key];
 
  el.addEventListener("input", () => {
    // 드래그 진행 중
    state[key] = el.valueAsNumber;
    applyFilters(state);
  });
 
  el.addEventListener("change", () => {
    // 드래그 완료, 변경 사항 저장
    localStorage.setItem("filters", JSON.stringify(state));
  });
});

이 코드는 각 범위 슬라이더의 초기 값을 설정하고, 사용자가 슬라이드할 때 필터를 업데이트하는 input 리스너를 연결합니다. 실시간 사용자 피드백을 위해 드래그 중에 지속적으로 핸들러가 호출되도록 change 대신 input을 사용합니다. 또한 마우스를 놓을 때만 발생하는 별도의 change 이벤트 리스너를 추가하여 변경 사항을 로컬스토리지에 저장합니다.

그리고 초기화 버튼입니다. 모든 것을 기본값으로 되돌리고, 로컬스토리지 키를 제거하고, 오버레이에서 필터를 제거하고, 범위 슬라이더를 초기화합니다.

// 초기화 버튼
const resetBtn = document.getElementById("vf-reset");
resetBtn.addEventListener("click", () => {
  state = {
    brightness: 100,
    contrast: 100,
    saturate: 100,
    hue: 0,
    sepia: 0,
    grayscale: 0,
    invert: 0,
  };
  localStorage.removeItem("filters");
 
  Object.keys(state).forEach((key) => {
    document.getElementById("vf-" + key).value = filters[key];
  });
 
  applyFilters(state);
});

이게 전부입니다! 직접 체험해보고 싶으신가요? 아래 버튼을 클릭해보세요.

채도를 끝까지 낮추면 되는데 왜 굳이 그레이스케일 슬라이더를 따로 제공하는지 궁금하실 수도 있습니다. 답은 실제로 약간 다르게 동작을 한다는 것입니다! 채도 감소는 빨강, 초록, 파랑 값을 평균 내어 색을 제거하는 산술적인 방식이지만, 전용 "그레이스케일" 필터는 실제로 더 인간의 시각 인지에 기반한 방식으로 처리하며, 우리 눈의 원추세포를 기반으로 서로 다른 색상에 "가중치"를 부여합니다. 솔직히 대부분의 사용자는 아마 눈치채지 못하거나 신경 쓰지 않을 테니, "완전성"을 위해 추가했다고 합시다, 하하.

그리고 왜 "반전(invert)" 슬라이더를 포함하냐고요? 재미있으니까요! 농담이고, 사용자의 접근성 요구가 무엇일지는 아무도 모릅니다. 누군가는 반전이 도움이 될 수 있습니다. 아, 그리고 100%로 반전한 뒤 색조를 +180으로 이동하면 "임시 다크 테마"가 됩니다.

향후 아이디어

사용자 경험을 더 향상시킬 수 있는 몇 가지 아이디어를 소개합니다.

  • 키보드 단축키: 사용자가 실수로 전체 UI를 숨겼을 때 컨트롤 패널을 불러올 수 있는 키를 제공한 다음, 컨트롤 대화 상자를 닫을 수 있게 합니다.
  • 영속성: 앱에 사용자 계정이 있다면, 모든 필터 선택을 서버에 저장하여 사용자가 다른 브라우저나 기기에서 다시 로그인해도 설정이 복원되도록 합니다.
  • 인쇄: @media print가 활성화되면 필터를 비활성화하는 것을 고려하세요.
  • 이미지 색조 보존: 앱에 이미지나 동영상이 포함되어 있다면, 해당 요소에 대해 색조 선택을 "반전"하여 원래 색상을 유지하는 것을 고려하세요.
  • 프리셋: "따뜻한", "차가운" 등의 슬라이더 프리셋 세트를 제공하세요.

요약

이게 전부입니다 — 오버레이 하나, 슬라이더 몇 개, 그리고 로컬스토리지. 사용자에게 단순한 라이트/다크 토글 이상의 것을 제공한 셈입니다: 사용자는 자신의 개인적인 편안함이나 접근성 요구에 맞게 경험을 조정할 수 있습니다.