원문: https://dev.37signals.com/modern-css-patterns-and-techniques-in-campfire/
37signals팀이 프레임워크나 전처리기(preprocessors) 없이 최신 기능과 순수 CSS를 사용하여 ONCE/Campfire 서비스를 어떻게 구축했는지 자세히 살펴보세요.
최근 ONCE/Campfire를 구매한 고객들을 대상으로 앱의 CSS 코드를 실시간으로 소개하는 자리에 초대했습니다. Campfire는 컴파일이나 전처리기를 사용하지 않고 바닐라 CSS를 사용해 완전히 #nobuild 로 구축 되었으며, 에버그린 브라우저에서 사용할 수 있는 최신 웹 플랫폼 기능인 CSS nesting, :has(), :is(), and :where(); wide-gamut colors, View Transitions 등을 사용합니다.
이 글에서는 소개된 여러 기능이 어떻게 사용하고 있는지 살펴보고 그 과정에서 발견한 몇 가지 유용한 패턴을 공유해 보겠습니다.
저희는 oklch()를 사용해 CSS에서 색상을 정의합니다. oklch()
는 Display-P3와 같은 더 넓은 색 영역에 대한 액세스를 제공하며 색상 작업 시 개발자의 개발 편의성을 크게 향상시킵니다. 예를 들어 Campfire의 UI에 사용된 회색을 살펴보겠습니다.
:root {
--lch-gray: 96% 0.005 96;
--lch-gray-dark: 92% 0.005 96;
--lch-gray-darker: 75% 0.005 96;
}
언뜻 보기에는 생소해 보일 수 있지만 실제로는 가독성이 높고 익숙해지면 사용하기 쉽습니다.
LCH는 약자로 다음을 의미합니다.
이를 염두에 두면 큰 노력 없이도 색상을 읽을 수 있습니다. 색상은 모두 동일한 색조와 채도를 공유하며 명도만 다르다는 것을 알 수 있습니다. 코드를 읽는 것만으로도 --lch-gray
과 --lch-gray-dark
는 명도는 비교적 비슷하지만 --lch-gray-darker
가 훨씬 더 어둡다는 것을 알 수 있습니다. 또한 부주의로 색상을 변경하지 않으면서 색상 선택기를 사용하지 않고도 프로그래밍적으로 또는 수동으로 간단히 수동으로 조정할 수 있습니다. RGB 색상을 사용해 본 적이 있다면 이 작업이 얼마나 까다로운지 잘 알고 있을 것입니다.
위에서 색상 값을 직접 정의했지만, 이를 oklch()
색상 함수로 감싸고 다른 스타일시트에서 사용할 값을 소비하는 추상적인 사용자 정의 프로퍼티 집합을 정의할 수도 있습니다.
--color-border: oklch(var(--lch-gray));
--color-border-dark: oklch(var(--lch-gray-dark));
--color-border-darker: oklch(var(--lch-gray-darker));
물론 회색은 쉬운데 다른 색상은 어떨까라고 생각할 수도 있겠죠? 다음은 링크와 선택 표시를 위한 파란색을 기반의 집합입니다.
--lch-blue: 54% 0.23 255;
--lch-blue-light: 95% 0.03 255;
--lch-blue-dark: 80% 0.08 255;
--color-link: oklch(var(--lch-blue));
--color-selected: oklch(var(--lch-blue-light));
--color-selected-dark: oklch(var(--lch-blue-dark));
이 값을 빠르게 읽어보면 세 가지 모두 동일한 계열의 색으로 동일한 색조(255º)로 표시됩니다. 또한 링크는 중간 정도의 명도와 채도를 가지고 있음을 알 수 있습니다. 밝은 베리에이션은 명도 값이 훨씬 높고 채도가 훨씬 낮아 회색에 가깝고, 어두운 베리에이션은 명도나 채도가 낮습니다. 일반적으로 밝은 값 주변의 테두리에는 어두운 베리에이션을 사용합니다.
더 좋은 점은 oklch()
를 사용하면 알파 투명도를 추가하는 것도 간단하다는 점입니다.
--color-link-50: oklch(var(--lch-blue) / 0.5);
CSS의 변수는 분명 새로운 것은 아니지만 작업을 즐겁게 만드는 몇 가지 일반적인 사용 패턴을 개발했습니다. Campfire buttons.css
에서 몇 가지 스타일을 살펴보겠습니다.
과거에 사용자 정의 프로퍼티를 사용할 때는 종종 규칙의 맨 위(또는 :root
)에 모든 사용자 정의
을 선언한 다음 바로 아래에서 사용하는 방식으로 설정했습니다. 예를 들면 아래와 같은 방식입니다.
.btn {
--btn-background: var(--color-text-reversed);
--btn-border-color: var(--color-border);
--btn-border-radius: 2em;
--btn-border-size: 1px;
--btn-color: var(--color-text);
--btn-padding: 0.5em 1.1em;
align-items: center;
background-color: var(--btn-background);
border-radius: var(--btn-border-radius);
border: var(--btn-border-size) solid var(--btn-border-color);
color: var(--btn-color);
display: inline-flex;
gap: 0.5em;
justify-content: center;
padding: var(--btn-padding);
}
이 방법은 잘 작동하지만 보일러 플레이트가 많다고 느껴지고 해당 변수를 다시는 사용하지 않을 수도 있다는 점에서 약간 방어적인 측면이 있습니다. 이때 폴백 값이 유용하게 사용됩니다. 규칙 맨 위에 수많은 프로퍼티를 나열하는 대신 기본값을 인라인으로 설정하되 다른 값이 있을 때 이를 적용하는 사용자 정의 프로퍼티를 노출할 수 있습니다. 다음과 같은 형태입니다.
color: var(--btn-color, var(--color-text));
여기서 --btn-color
는 선택 사항입니다. 설정하면 규칙에 따라 해당 값을 사용하며, 설정하지 않으면 폴백 값인 --color-text
로 설정됩니다. 폴백 값은 고정값이나 다른 변수가 될 수 있습니다. 이제 위의 규칙대로 다음과 같이 다시 작성할 수 있습니다.
.btn {
align-items: center;
background-color: var(--btn-background, var(--color-text-reversed));
border-radius: var(--btn-border-radius);
border: var(--btn-border-size, 2em) solid var(--btn-border-color, var(--color-border));
color: var(--btn-color, var(--color-text));
display: inline-flex;
gap: 0.5em;
justify-content: center;
padding: var(--btn-padding, 0.5em 1.1em);
}
이것은 더 엄격하며 모든 기본값과 노출된 변수가 인라인으로 함께 표시됩니다.
그렇다면 사용자 정의 프로퍼티를 어디에 사용할지 어떻게 결정할까요? 사실 두 가지 경우가 있습니다. 1) 동일한 값을 두 곳 이상에서 사용해야 할 때(DRY), 2) 값이 변경될 것을 알고 있을 때입니다.
첫 번째 경우의 좋은 예는 --btn-size
변수입니다. Campfire의 거의 모든 버튼은 내부에 아이콘이 있는 원 모양입니다. 입력 필드와 잘 정렬되도록 하기 위해 이 변수를 사용하여 블록 크기를 설정합니다.
이 크기는 :root
레벨에 노출되므로 button과 input 요소에 사용할 수 있습니다. 더 좋은 점은 이 값을 사용하여 레이아웃에서 채팅 바닥글의 높이를 계산할 수 있다는 것입니다. 매직 넘버는 보이지 않습니다!
:root {
--btn-size: 2.65em;
}
body {
--footer-height: calc((var(--block-space)) + var(--btn-size) + var(--block-space));
grid-template-rows: 1fr var(--footer-height);
}
바닥글의 높이는 버튼의 높이와 글로벌 --block-space
변수를 사용해 위와 아래에 패딩을 더한 값으로 구성됩니다.
사용자 정의 프로퍼티를 사용하는 다른 경우는 요소의 베리에이션을 만들기 위해 일부 값을 변경하고 싶을 때입니다. CSS 클래스를 위한 미니 API라고 생각하면 됩니다. 버튼 클래스로 돌아가서 프로퍼티를 재정의하는 대신 사용자 정의 프로퍼티의 값을 변경하는 것만으로 베리에이션을 선언할 수 있습니다.
/* 베리에이션들 */
.btn--reversed {
--btn-background: var(--color-text);
}
.btn--negative {
--btn-background: var(--color-negative);
}
:is(.btn--reversed, .btn--negative) {
--btn-color: var(--color-text-reversed);
}
.btn--borderless {
--btn-border-color: transparent;
}
.btn--success {
animation: success 1s ease-out;
img {
animation: zoom-fade 300ms ease-out;
}
}
이렇게 하면 이러한 베리에이션으로 인해 무엇이 변경되었는지 매우 명확하게 알 수 있습니다. 더 좋은 점은 .btn--success
의 경우처럼 기본 프로퍼티 값을 변경하는 것과 새 프로퍼티(이 경우 animation
프로퍼티)를 추가하는 것을 명확하게 구분할 수 있다는 점입니다.
이전에 서버 측 코드에서 수행해야 했던 작업을 CSS로 수행할 수 있는 여러 가지 편리함과 기회를 제공하기 때문에 Campfire 개발 초기 단계부터 :has()
를 사용하기 시작했습니다. 저희는 주요 브라우저들이 :has()
를 지원할 것이라는데 매우 낙관적이었기 때문에 주요 브라우저 중 마지막으로 :has()
를 지원한 Firefox 버전이 출시되기 일주일 전에 Campfire의 첫 번째 베타 버전을 출시했습니다.
:has()
는 요소의 내부에 무엇이 있는지 쿼리하는 방법이라고 생각하면 됩니다.
이는 버튼 클래스를 매우 유연하게 만듭니다. 그 안에 어떤 조합이든 넣을 수 있으며 그에 따라 조정됩니다. 텍스트만, 이미지와 텍스트, 이미지만, 입력(예: 라디오 버튼) 또는 텍스트와 여러 이미지가 포함될 수 있습니다.
예를 들어, .btn
클래스는 그 안에 아바타 사진이 아닌 이미지를 발견하면 특별한 클래스 없이도 크기를 조절하고 어두운 모드에서 반전되도록 할 수 있습니다.
.btn {
...
img {
-webkit-touch-callout: none;
user-select: none;
}
&:where(:has(img):not(.avatar)) {
text-align: start;
img {
filter: invert(0);
inline-size: 1.3em;
max-inline-size: unset;
@media (prefers-color-scheme: dark) {
filter: invert(100%);
}
}
}
Campfire의 버튼 대부분에는 아이콘 이미지와 스크린 리더를 위한 숨겨진 텍스트 요소가 포함되어 있습니다.
<%= form.button class: "btn btn--reversed center", type: "submit" do %>
<%= image_tag "check.svg", aria: { hidden: "true" }, size: 20 %>
<span class="for-screen-reader">Save changes</span>
<% end %>
:has()
를 사용하면 버튼 클래스가 이러한 요소의 존재 여부를 파악하여 이미지가 가운데에 있는 원 아이콘 버튼으로 바꿀 수 있습니다. 앞서 설명드렸던 --btn-size
변수를 사용하고 있음을 알 수 있습니다.
&:where(:has(.for-screen-reader):has(img)) {
--btn-border-radius: 50%;
--btn-padding: 0;
aspect-ratio: 1;
block-size: var(--btn-size);
display: grid;
inline-size: var(--btn-size);
place-items: center;
> * {
grid-area: 1/1;
}
}
.btn
에 원하는 내용을 입력하기만 하면 나머지는 자동으로 처리됩니다.
개발자로서 정말 만족스러운 기능이지만 .btn--circle-icon
또는 .btn--icon-and-text
와 같은 유틸리티 클래스를 사용하면 많은 노력 없이도 이 작업을 수행할 수 있습니다. 정말 눈을 뜨게 된 것은 Ruby on Rails 코드를 CSS만으로 대체할 수 있게 되었을 때였습니다.
좁은 뷰포트에서 Campfire를 사용할 때 사이드바를 전환하는 메뉴 버튼을 예로 들어보겠습니다.
사이드바(모든 대화방을 나열하는)는 닫혀 있을 때 숨겨지기 때문에 메뉴 버튼에 작은 점을 표시하여 읽지 않은 새 메시지가 있는 대화방이 있음을 표시하고 싶었습니다. 일반적으로는 이와 같은 조건을 처리하기 위해 다음과 같이 Ruby on Rails 코드를 작성해야 합니다.
<% if @room.memberships.unread.any? %>
// 점을 렌더링
<% end %>
하지만 :has()
를 사용하면 순수한 CSS만으로도 가능합니다!
#sidebar:where(:not([open]):has(.unread)) & {
&::after {
--size: 1em;
aspect-ratio: 1;
background-color: var(--color-negative);
block-size: var(--size);
border-radius: calc(var(--size) * 2);
content: "";
flex-shrink: 0;
inline-size: var(--size);
inset-block-start: calc(var(--size) / -4);
inset-inline-end: calc(var(--size) / -4);
position: absolute;
}
}
여기서는 사이드바 요소를 쿼리하여 1) 열려 있지 않은지 확인하고(이미 회의실 목록을 보고 있다면 점을 볼 필요가 없으므로) 2) 그 안에 .unread
클래스를 가진 요소가 있는지 확인합니다. 해당 요소가 있으면 점을 그려서 배치합니다. 여기서 점의 크기와 테두리 반경 및 위치를 계산하기 위해 사용자 정의 프로퍼티(--size
)를 사용하고 있음을 알 수 있습니다. 이는 조화로우며 매직넘버를 피할 수 있습니다.
또 다른 예로, Campfire의 계정 프로필 화면에서는 서버 측 코드로도 거의 불가능했던 문제를 해결하기 위해 :has()
를 사용했습니다. 이 화면에는 현재 참여 중인 모든 대화방 목록과 각 대화방의 상태를 전환할 수 있는 버튼이 있습니다. 사이드바에서 대화방을 보이지 않게 설정한 경우 행을 회색으로 표시하여 이 중요한 상태를 시각적으로 강조할 수 있도록 했습니다.
문제는 토글 버튼이 Turbo Frame에서 렌더링 된 다른 컨트롤러를 사용하는 완전히 별개의 요소라는 점입니다. 이는 방에 자체적으로 표시되는 토글과 동일한 토글입니다. 즉, 행을 렌더링하는 코드는 버튼의 상태가 어떤 상태인지 알 수 없으며 언제 상태가 변경되는지도 알 수 없습니다.
<li class="flex align-center gap margin-none min-width membership-item">
<%= link_to room_path(membership.room), class: "overflow-ellipsis fill-shade txt-primary txt-undecorated" do %>
<strong><%= room_display_name(membership.room) %></strong>
<% end %>
<hr class="separator" aria-hidden="true">
<span class="txt-small">
<%= turbo_frame_tag dom_id(membership.room, :involvement) do %>
<%= button_to_change_involvement(membership.room, membership.involvement) %>
<% end %>
</span>
</li>
물론 이제 자바스크립트를 사용하여 상태를 가져오고, 변경 사항을 관찰하고, 뷰를 업데이트할 수 있습니다. 또는 이 코드를 다시 작성하여 알림 상태가 변경될 때 전체 행을 다시 렌더링할 수도 있지만, 그러면 다른 곳에서 사용된 것과 약간만 다른 중복된 토글을 작성하게 됩니다.
세 번째 방법은 단일 CSS 규칙을 작성하는 것입니다!
.membership-item:has(.btn.invisible) {
opacity: 0.5;
}
행에 .invisible
클래스로 전환된 버튼이 있는 경우 해당 버튼을 흐리게 표시합니다.
CSS의 발전은 지난 몇 년 동안 천천히 자바스크립트 코드를 대체해왔고, 이제 서버 측 코드도 대체될 것입니다!
Campfire의 쪽지 기능인 '핑(ping)'은 사이드바 상단에 모든 활성 대화가 표시되는 기능입니다. 참여 인원에 따라 Campfire는 채팅을 대표하는 아바타를 하나, 둘, 셋 또는 네 개로 표시합니다.
일반적으로 뷰 템플릿은 참여 인원을 계산하고 요소에 조건부로 클래스를 적용하여 CSS가 각 레이아웃 그룹을 렌더링하는 방법을 알 수 있도록 해야 합니다. 하지만 :has()
를 사용하면 요소의 수를 효과적으로 계산하고 그에 따라 표시를 조정할 수 있습니다.
/* 아바타가 4개일 때 */
.avatar__group {
--avatar-size: 2.5ch;
block-size: 5ch;
display: grid;
gap: 1px;
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content;
inline-size: 5ch;
place-content: center;
.avatar {
margin: auto;
}
/* 아바타가 2개일 때 */
&:where(:has(> :last-child:nth-child(2))) {
--avatar-size: 3.5ch;
> :first-child {
margin-block-end: 1.5ch;
margin-inline-end: -0.75ch;
}
> :last-child {
margin-block-start: 1.5ch;
margin-inline-start: -0.75ch;
}
}
/* 아바타가 세개일 때 */
&:where(:has(> :last-child:nth-child(3))) {
> :last-child {
margin-inline: 1.25ch -1.25ch;
}
}
}
짜잔🪄
마지막 섹션에서는 반응형 디자인에 대한 Campfire의 접근 방식을 살펴보겠습니다. 가장 먼저 알아야 할 것은 Campfire에는 @media
쿼리를 기반으로 하는 뷰포트가 전혀 없다는 것입니다. x보다 좁은 뷰포트는 모바일 디바이스라고 단정 짓지 않습니다. Campfire의 레이아웃은 어떤 상태를 "모바일"로 선언하지 않고도 어떤 구성이나 방향으로 어떤 기기를 사용하든 완벽하게 적응합니다. 방법은 다음과 같습니다.
Campfire에는 여러 곳에서 사용되는 단일 @media
분기점(breakpoint)이 있습니다.
@media (max-width: 100ch) {
...
}
분기점은 주로 뷰포트가 너무 좁아 사이드바를 채팅 내용 옆에 표시할 수 없을 때 CSS 그리드 레이아웃이 어떻게 조정되어야 하는지를 결정합니다. 문서가 100자보다 좁은 경우 나란히 렌더링하는 것은 실용적이지 않으므로 대신 Campfire는 사이드바를 숨기고 메뉴 버튼을 표시하도록 전환합니다.
문자를 측정 단위로 사용하면 어떤 기기를 사용하든, iPad에서 멀티태스킹을 하거나 글꼴 크기를 특정 지점 이상으로 확대하는 등 다양한 상황에서 올바른 동작을 보장할 수 있습니다. 글꼴은 웹 페이지의 핵심이므로 레이아웃이 이에 반응하는 것이 당연합니다.
미디어 쿼리를 사용하는 또 다른 목적은 사용자가 어떤 입력 장치를 가지고 있는지에 따라 대응하는 것입니다. 뷰포트가 좁은 디바이스가 반드시 터치스크린이 있다고 가정하거나 뷰포트가 큰 디바이스에 터치스크린이 없다고 가정하는 것은 결코 옳지 않으며, 이 모호한 경계는 좀처럼 명확해지지 않고 있습니다. 하지만 @media
쿼리 덕분에 디바이스의 기능에 대한 유용한 정보를 얻을 수 있게 되었습니다. 먼저, any-hover를 살펴보겠습니다.
@media (any-hover: hover) {
&:where(:not(:active):hover) {
/* 호버 이펙트 */
}
}
이는 사용자의 디바이스에 마우스와 같이 호버링이 가능한 입력 메커니즘이 있는지 쿼리합니다. 터치스크린 기기에서는 적용되지 않으며, 호버 이펙트가 있는 항목을 두 번 탭하게 만드는 모바일 사파리의 성가신 동작을 방지합니다. 나쁘지 않습니다.
역주: 모바일 사파리는
:hover
를 사용한 스타일이 적용되어 있으면 처음 탭했을 때 호버를 두 번째 탭했을 때 클릭을 트리거해 두번 탭을 해야 클릭되는 문제가 있습니다. 자세한 내용은 다음 글을 참고하세요!
iOS has a :hover problem
하지만 좀 더 인상적인 부분을 살펴봅시다. 캠프파이어 채팅의 모든 메시지 라인에는 ••• 버튼이 있어 사용자가 할 수 있는 추가 작업(편집, 부스트, 복사, 공유) 메뉴가 표시됩니다.
마우스나 트랙패드가 있는 디바이스에서는 메시지 위로 마우스를 가져갔을 때만 메뉴를 표시하는 것이 가장 이상적이지만, 그렇게 하면 터치 디바이스에서는 액세스할 수 없게 됩니다. pointer 쿼리와 함께 any-hover
를 사용하면 각 종류의 디바이스에서 아무 문제없이 원하는 대로 동작하게 할 수 있습니다.
@media (any-hover: hover) and (pointer: fine) {
/* 마우스오버 시에만 버튼 표시 */
}
@media (any-hover: none) and (pointer: coarse) {
/* 항상 버튼 표시 */
}
iPad Pro와 같은 기기에서는 특히 마법과도 같은 기능입니다. 특정 조건에서 두 쿼리를 모두 매치 할 수 있고 즉각 변경할 수도 있습니다. 트랙패드가 내장된 매직 키보드에 연결하면 첫 번째 쿼리에 매치되고 ••• 버튼은 마우스를 가져갈 때까지 숨겨집니다. 매직 키보드에서 떼어내 순수한 터치 디바이스가 되면 ••• 버튼이 마법처럼 나타납니다. 정말 멋지죠.
Campfire 1.0은 2024년 1월에 출시 되었으며 3월에는 이미 다음 ONCE 제품 개발에 착수했습니다. Campfire가 출시될 당시에는 최첨단 기능을 지원했지만 웹 플랫폼은 빠르게 변화하고 있으며, 그 이후로 브라우저에서 지원되는 새로운 기능도 이미 살펴보고 있습니다. 웹에서 작업하기에 환상적인 시기입니다.
한번 구매하면 소스 코드를 포함하여 영원히 소유하고 원하는 작업을 할 수 있는 제품군 중 첫 번째 제품인 Campfire를 아직 사용해 보지 않으셨다면 지금 once.com에서 사용하실 수 있습니다.
질문, 의견 또는 아이디어가 있으신가요? 이와 같은 글을 더 보고 싶으신가요? jz@37signals.com 나 x.com/jasonzimdars로 문의해주세요.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!