
원문 : https://master.dev/blog/fluid-typography-with-progress/
“em과 rem 단위를 사용할 때, 픽셀 기준으로 계산하려고 해서는 안 됩니다.
우리가 잘못된 방향으로 흘러가기 시작한 이유는 16px이 언제나 1em과 같다고 당연하게 가정했기 때문입니다.”— Miriam Suzanne, Reimagining Fluid Typography
유동적인 타이포그래피는 등장한 지 꽤 오래되었습니다. 요즘 대부분의 사람들은 clamp()를 사용하고, Utopia, Fluid Type Scale, Sass 같은 도구를 통해 미리 계산된 값을 가져와 활용합니다. 현대적이고, 간결하며, 사용하기도 매우 쉽기 때문입니다.
아마 여러분도 비슷한 계산기를 한 번쯤 사용해봤을 겁니다. 두 가지 크기 값과 각각에 대응하는 브레이크포인트를 입력하면, 그 사이에서 자연스럽게 크기가 변화하는 값을 자동으로 생성해줍니다.
보통 우리는 이런 숫자들을 픽셀 단위로 입력하고, 대부분의 좋은 계산기는 폰트 크기를 rem 단위로 변환해줍니다. 이것 역시 이런 방식이 매력적인 이유 중 하나입니다.
하지만 여러분이 신중하게 선택한 브레이크포인트 값들 역시 함께 rem 단위로 변환되며, 결국 사용자의 기본 폰트 크기 설정의 영향을 받게 된다는 사실은 미처 인식하지 못했을 수도 있습니다.
실제로 이것은 브레이크포인트가 우리가 예상했던 것과 전혀 다른 지점에서 적용될 수 있다는 의미입니다. 그리고 그 과정에서 의도하지 않게 사용자가 설정한 폰트 크기 선택을 무시하게 될 수도 있습니다.
이번 글에서는 왜 이런 현상이 발생하는지 살펴보고, 최신 CSS를 활용해 브라우저 내부에서 직접 모든 값을 계산하는 범위 매핑 방식을 통해 이를 어떻게 해결할 수 있는지 알아보겠습니다.
본격적으로 시작하기 전에 한 가지 미리 말씀드리자면, 이번에 소개하는 방식은 여러분이 기존에 사용하던 방법보다 조금 더 복잡할 수 있습니다. 하지만 커스텀 프로퍼티 덕분에 쉽게 재사용할 수 있으며, 앞으로 CSS가 발전하면 훨씬 더 간결하게 작성할 수 있게 될 것입니다.
예를 들어, 다음과 같은 값으로 유동적인 타이포그래피를 설정했다고 가정해봅시다.
| Viewport | Font Size |
|---|---|
| 600px | 12px |
| 1200px | 32px |
그러면 계산기는 내부적으로 이 값을 자동으로 rem 단위로 변환해줍니다.
| Viewport | Font Size |
|---|---|
| 37.5rem | 0.75rem |
| 75rem | 2rem |
이제 화면 너비가 1200px이고, 기본 루트 폰트 크기가 16px인 사용자를 생각해봅시다. 2rem은 32px이므로, 우리가 예상한 것과 정확히 일치합니다.
그런데 만약 사용자가 기본 폰트 크기를 32px로 변경한다면 어떨까요?
모든 값은 여전히 rem 단위이지만, 이제 1rem = 32px이 됩니다. 그러면 결과는 이렇게 달라집니다.
| Viewport | Font Size |
|---|---|
| 1200px | 24px |
| 2400px | 64px |
같은 1200px 화면 너비에서도 폰트 크기는 이제 24px밖에 되지 않습니다.
뷰포트 크기는 전혀 변하지 않았고, 사용자는 글자 크기를 더 크게 설정했는데도, 우리가 만든 유동적인 타이포그래피는 오히려 더 작아진 것입니다. 결과적으로 사용자의 접근성 설정과 정반대로 동작하게 됩니다.
물론 이는 극단적인 예시이지만, 미리 계산된 모든 유동적인 값들은 정도의 차이만 있을 뿐 동일한 문제를 가지고 있습니다.
그렇다면 이 문제를 어떻게 해결할 수 있을까요?
우리는 계산 과정을 다시 CSS 내부로 가져와야 합니다. 그리고 픽셀 기반 브레이크포인트의 정확성과 rem 기반 폰트 크기의 접근성을 모두 활용할 수 있도록 서로 다른 단위를 함께 사용해야 합니다.
이 계산을 처리하는 방법은 여러 가지가 있지만, 그중에서도 범위 매핑은 CSS 환경에 특히 잘 맞는 방식입니다. 특히 앞으로 등장할 progress()나 calc-mix() 같은 CSS 기능들과도 자연스럽게 연결되기 때문에, 미래에는 이런 계산을 훨씬 간단하게 만들 수 있다는 점에서 더욱 매력적입니다.
이 내용은 유동적인 타이포그래피가 원래 크기 대비 200%까지 확대될 수 있도록 보장해야 하는 문제와도 밀접하게 관련되어 있습니다. 다만 아쉽게도 이번에 소개하는 방식만으로는 그 문제까지 해결할 수 없으며, 해당 부분은 여전히 별도의 수동 테스트가 필요합니다.
범위 매핑은 하나의 범위에 있는 값을 다른 범위로 변환하면서도, 해당 값이 원래 가지고 있던 상대적인 위치를 그대로 유지하는 과정입니다. 유동적인 타이포그래피에서는 현재 뷰포트 너비를 기준으로, 우리가 설정한 출력 범위 안에서 대응되는 값을 계산해야 합니다.
이번 데모에서는 한쪽 범위에 존재하는 어떤 값이든, 다른 범위에도 이에 대응하는 값이 존재한다는 것을 확인할 수 있습니다.
다만 시각적으로 확인하기에는 출력 범위가 다소 크게 설정되어 있기 때문에, CSS에서는 조금 더 현실적인 값으로 다시 정의해보겠습니다.
--min-size: 1rem;
--min-size-viewport: 400px;
--max-size: 1.5rem;
--max-size-viewport: 1200px;
먼저, 현재 뷰포트가 우리가 설정한 범위 안에서 어느 위치에 있는지 계산해야 합니다.
불과 얼마 전까지만 해도 이 값을 계산하는 일은 생각보다 꽤 까다로웠습니다. 단위가 없는 숫자로 값을 변환하기 위해 복잡한 CSS 우회 기법에 의존해야 했고, 그만큼 전체 계산 과정 역시 훨씬 복잡해질 수밖에 없었습니다.
다행히 최신 CSS에서는 이 문제를 정확히 해결해주는 새로운 함수가 등장했습니다.
--progress: progress(100vi, var(--min-size-viewport), var(--max-size-viewport));
MDN에서는 이렇게 설명합니다.
progress()CSS 함수는 하나의 값이 두 개의 다른 값 사이에서 어느 위치에 있는지를 나타내는 숫자 값을 반환합니다.
바로 우리가 필요했던 기능입니다.
이 함수가 특히 유용한 이유는 몇 가지 중요한 특징 때문입니다.
같은 유형의 단위라면 서로 다른 단위를 함께 사용할 수 있습니다. 덕분에 현재 뷰포트를 측정하기 위해 100vi를 사용하고, 이를 픽셀 단위 또는 원하는 길이 단위로 정의한 범위와 비교할 수 있습니다.
반환값이 단위가 없는 숫자이기 때문에 이후 계산 과정이 훨씬 단순해집니다.
반환되는 숫자는 항상 0과 1 사이의 값이므로, 최소값과 최대값 사이에서 자동으로 크기가 제한됩니다.
이제 progress()를 사용하면 더 이상 뷰포트 브레이크포인트를 rem 단위로 변환할 필요가 없습니다. 덕분에 앞서 이야기했던 기존 유동적인 타이포그래피 방식의 문제를 완전히 우회할 수 있게 됩니다.
다음으로 해야 할 일은 출력 범위의 간격을 구하는 것입니다. 즉, 전체 범위가 얼마나 되는지를 계산해야 합니다. 이 값은 최대값에서 최소값을 빼주면 간단하게 구할 수 있습니다.
/* 1.5rem - 1rem = 0.5rem */
--output-span: calc(var(--max-size) - var(--min-size));
이제 progress()를 통해 구한 현재 뷰포트 값을 이용해, 이 범위를 비율에 맞게 조정할 수 있습니다. 방법은 계산된 범위에 progress() 값을 곱해주는 것입니다.
/* (0-1) * 0.5rem */
--fluid: calc(var(--progress) * var(--output-span));
이렇게 계산하면 값의 시작점이 0부터 시작하게 됩니다. 따라서 실제 우리가 원하는 시작 지점에 맞추기 위해 최소 크기 값을 더해서 시작 위치를 보정해주어야 합니다.
/* 1rem + (0-1) * 0.5rem */
--fluid: calc(var(--min-size) + var(--progress) * var(--output-span));
이제 값은 우리가 설정한 범위의 시작부터 끝까지 올바르게 변화하게 되었고, 최종 계산식이 완성되었습니다. 아래는 지금까지의 내용을 모두 하나로 정리한 코드입니다.
--min-size: 1rem;
--min-size-viewport: 400px;
--max-size: 1.5rem;
--max-size-viewport: 1200px;
--_progress: progress(100vi, var(--min-size-viewport), var(--max-size-viewport));
--_output-span: calc(var(--max-size) - var(--min-size));
--fluid: calc(var(--min-size) + var(--_progress) * var(--_output-span));
이제 이것을 전체 스케일로 확장해보겠습니다. 각 단계는 이전 단계의 값을 기준으로, 사용자 정의 비율을 적용해 계산되도록 구성할 것입니다. 일반적으로 최소 크기와 최대 크기에 서로 다른 비율을 사용하는 방식이 많이 활용되기 때문에, 이번에도 같은 방식으로 진행해보겠습니다.
먼저 CSS에서 비율 값을 기존 설정과 함께 정의해보겠습니다.
--min-size: 1rem;
--min-size-viewport: 400px;
--min-size-ratio: 1.2;
--max-size: 1.5rem;
--max-size-viewport: 1200px;
--max-size-ratio: 1.25;
--_progress: progress(100vi, var(--min-size-viewport), var(--max-size-viewport));
이제 스케일의 각 단계를 만들어볼 차례입니다. 지금까지 살펴본 내용을 바탕으로 먼저 기본 크기부터 만들어보겠습니다.
--step-0: calc(
var(--min-size) + var(--_progress) * (var(--max-size) - var(--min-size))
);
다음 단계 값을 계산하려면 최소 크기와 최대 크기에 사용된 각각의 값에 비율을 곱해주면 됩니다. 이 값을 저장하기 위해 별도의 커스텀 프로퍼티를 사용해보겠습니다.
--_min-1: calc(var(--min-size) * var(--min-size-ratio));
--_max-1: calc(var(--max-size) * var(--max-size-ratio));
--step-1: calc(
var(--_min-1) + var(--_progress) * (var(--_max-1) - var(--_min-1))
);
그렇다면 Step 2는 어떻게 계산할까요? 각 크기 값에 비율을 두 번 곱하면 됩니다. 다행히도 이는 pow() 함수를 사용해 비율 값을 제곱하는 것과 동일합니다.
--_min-2: calc(var(--min-size) * pow(var(--min-size-ratio), 2));
--_max-2: calc(var(--max-size) * pow(var(--max-size-ratio), 2));
--step-2: calc(
var(--_min-2) + var(--_progress) * (var(--_max-2) - var(--_min-2))
);
사실 어떤 수든 1제곱은 자기 자신이고, 0제곱은 항상 1이기 때문에, 같은 계산 방식을 스케일의 모든 단계에 그대로 적용할 수 있습니다. 심지어 음수를 사용하면 더 작은 크기를 만드는 것도 가능합니다.
즉, 특정 단계 n에 대해서는 최소값과 최대값 각각의 비율을 n제곱하여 계산하면 됩니다.
이제 이 개념을 바탕으로, 나머지 스케일을 정의하는 방법에는 몇 가지 선택지가 있습니다.
혹시 Jhey Tompkins의 데모 코드를 본 적이 있다면, 그가 지금까지 우리가 살펴본 방식과 매우 비슷한 유동적인 타이포그래피 기법을 자주 사용한다는 것을 눈치챘을지도 모릅니다.
그는 각 단계마다 값을 직접 하드코딩하는 대신, 커스텀 프로퍼티를 통해 지수 값을 설정하는 방식을 사용합니다. 이를 통해 코드 중복 없이 무한한 스케일을 만들 수 있습니다.
하지만 단점도 있습니다.
이 방식은 하나의 요소 전체에 대해 단 하나의 값만 사용할 수 있기 때문에, 서로 다른 단계 값을 필요로 하는 다른 속성들에는 별도로 적용할 수 없습니다.
만약 이미 Sass를 사용하고 있다면, 전체 스케일을 만드는 일은 @for 반복문 하나면 충분합니다.
각 단계마다 변경되는 값은 몇 가지 숫자뿐이기 때문에, 나머지 스케일 코드는 손쉽게 복사해서 붙여넣을 수 있습니다.
이것으로 모든 작업이 끝났습니다. 이제 그대로 프로젝트에 복사해서 붙여넣고, 필요한 부분만 수정해 원하는 스케일 설정에 맞게 조정하면 됩니다.
우리가 사용하는 조건 값은 단위로 표현할 수만 있다면 어떤 값이든 사용할 수 있습니다. 이번 예시에서는 100vi를 사용했지만, 이를 100cqi로 쉽게 변경해서 가장 가까운 컨테이너의 너비를 기준으로 반응하도록 만들 수도 있습니다.
--progress: progress(100qi, var(--min-size-container), var(--max-size-container));
만약 정수 단위만 사용하고 싶거나, 특정 베이스라인 그리드에 맞춰야 하는 경우라면 round() 함수를 사용해 원하는 반올림 간격으로 값을 조정할 수 있습니다.
--interval: 1px;
--step-0: round(
var(--min-size) + var(--_progress) * (var(--max-size) - var(--min-size)),
var(--interval));
그렇다면 뷰포트가 커질수록 스케일이 오히려 작아지도록 만들 수는 없을까요?
실무에서는 흔하게 필요한 경우는 아니겠지만, 다행히 범위 매핑 계산 방식은 이미 이런 역방향 관계도 자연스럽게 처리할 수 있습니다. 방법은 간단합니다. 뷰포트 값을 서로 바꿔주기만 하면 됩니다.
--min-size: 1rem;
--min-size-viewport: 1200px;
--max-size: 1.5rem;
--max-size-viewport: 400px;
현재 가장 큰 제약 요소는 progress()입니다. 이 함수는 Firefox를 제외한 대부분의 최신 브라우저에서 지원되기 때문에, 아직 바로 프로덕션 환경에 적용하기에는 어려움이 있습니다.
만약 브라우저 지원 여부를 확인한 뒤 기존 방식의 스케일로 대체하고 싶다면, 비교적 간단하게 폴백 처리를 적용할 수 있습니다.
@supports (opacity: progress(1px,0px,1px) {
...
}
사실 progress() 없이도 몇 가지 CSS 우회 기법을 사용해 직접 값을 계산하면 주요 브라우저 전체를 지원하는 것도 가능합니다. 아래는 그 방법을 보여주는 몇 가지 데모입니다.
다만 참고할 점은, 지금까지 살펴본 방식보다 코드가 훨씬 더 장황해질 수 있다는 것입니다.
정말 큰 발전입니다. 앞으로는 이런 변화가 일어나게 됩니다.
@function --fluid-size(--step) {
--min-size: 1rem;
--min-size-viewport: 400px;
--min-size-ratio: 1.2;
--max-size: 1.5rem;
--max-size-viewport: 1200px;
--max-size-ratio: 1.25;
--_progress: progress(100vi, var(--min-size-viewport), var(--max-size-viewport));
--_min-size: calc(var(--min-size) * pow(var(--min-size-ratio), var(--step)));
--_max-size: calc(var(--max-size) * pow(var(--max-size-ratio), var(--step)));
result: calc-mix(var(--_progress), var(--_min-size), var(--_max-size));
}
h1 {
font-size: --fluid-size(4);
}
글의 시작 부분에서도 이야기했고, 아마 지금까지 보면서 이미 느꼈겠지만, CSS에서 유동적인 스케일을 계산하는 과정은 상당히 장황합니다. 하지만 @function을 사용하면 이야기가 달라집니다. 각 단계마다 별도의 변수를 하나씩 계산하는 대신, 모든 계산 로직을 하나의 함수 안에 넣고 필요한 곳에서 호출해서 사용할 수 있기 때문입니다.
현재는 Chromium 기반 브라우저에서만 지원됩니다.
Juan Diego Rodríguez는 CSS 함수에 대해 더 자세하게 설명하고 있습니다.
조금 더 먼 미래에는 calc-mix() 덕분에 코드가 훨씬 더 간결해질 것입니다. 이 함수는 지금까지 우리가 직접 작성했던 나머지 범위 매핑 계산 과정을 대신 처리해줍니다.
이 함수는 총 3개의 값을 입력받습니다. progress()를 통해 얻은 정규화된 값과, 출력 범위의 시작값 그리고 끝값입니다.
calc-mix(var(--_progress), var(--_min-size), var(--_max-size))
아직 calc-mix()를 지원하는 브라우저는 없습니다. 하지만 그때까지는 지금처럼 직접 계산하는 방식을 계속 사용할 수 있습니다.
How To Map A Number Between Two Ranges
범위 매핑에 대해 더 깊이 이해하고 싶다면, Henry가 정리한 훌륭한 설명을 참고해보는 것을 추천합니다.
Precise Control Over Responsive Typography
대부분의 CSS 수학 함수나 커스텀 프로퍼티가 등장하기도 전인 2015년에 작성된 Mike의 글입니다. 현재 우리가 사용하는 정밀한 유동적 타이포그래피 개념을 설명한 가장 이른 사례 중 하나이며, 놀랍게도 이번 글에서 다룬 것과 동일한 범위 매핑 방식을 사용하고 있습니다.
Responsive Type and Zoom
Adrian Roselli는 접근성 관점에서 고려해야 할 문제들을 다시 짚어주며, 어쩌면 우리가 유동적인 타이포그래피 자체를 반드시 사용할 필요는 없을지도 모른다고 이야기합니다.
Addressing Accessibility Concerns With Using Fluid Type
Maxwell Barvian은 모든 텍스트가 원래 크기의 200%까지 확대될 수 있도록 보장함으로써, 접근성 기준인 1.4.4 Resize Text(AA)를 충족하는 방법에 대해 조언합니다.
Reimagining Fluid Typography & In Defence of Fluid Typography
Miriam Suzanne과 Richard Rutter가 유동적인 타이포그래피의 장점과 필요성에 대해 서로 다른 관점에서 논의를 이어가는 글입니다.