웹 개발의 세계는 빠르게 변하고 있습니다. 새로운 기술과 도구가 지속적으로 등장하면서, 개발자들은 가장 효율적이고 현대적인 방법을 찾기 위해 끊임없이 노력하고 있습니다. 특히 CSS는 웹 디자인의 핵심 요소로서, 최신 기능과 패턴을 적용하는 것이 중요합니다. 최근 37signals 팀은 ONCE/Campfire 프로젝트를 통해 프레임워크나 전처리기 없이도 순수 CSS만으로 강력한 웹 애플리케이션을 구축할 수 있음을 증명했습니다. 그들은 최신의 모던 CSS 패턴인 :has()
, :is()
, :where()
와 같은 선택자, wide-gamut colors, View Transitions 등을 사용하여 모던한 CSS 패턴을 구현했습니다.
이 글에서는 Campfire 프로젝트를 통해 소개된 최신 CSS 기능과 이를 활용한 유용한 패턴에 대해 자세히 살펴보겠습니다. 이러한 정보는 CSS를 더욱 효율적으로 활용하고자 하는 모든 웹 개발자들에게 유익한 자료가 될 것입니다. 최신 기술을 활용한 Campfire의 접근 방식을 통해 웹 개발의 새로운 가능성을 확인해 보세요.
oklch() 함수의 활용
CSS에서 색상을 정의하는 방법은 웹 개발의 중요한 부분입니다. 37signals 팀은 Campfire 프로젝트에서 oklch()
함수를 사용하여 색상을 정의했습니다. 이 함수는 더 넓은 색 영역을 제공하며, 개발자에게 색상 작업의 편의성을 크게 향상시킵니다.
:root { --lch-gray: 96% 0.005 96; --lch-gray-dark: 92% 0.005 96; --lch-gray-darker: 75% 0.005 96; }
LCH는 처음에는 다소 생소해 보일 수 있지만, 실제로는 가독성이 높고 익숙해지면 사용하기 쉽습니다. LCH는 Lightness(명도), Chroma(채도), Hue(색조)를 의미합니다. 각 속성은 다음과 같은 범위를 가집니다:
- Lightness(명도): 0%~100%
- Chroma(채도): 0~0.5
- Hue(색조): 0~360도
이 값을 이해하면 색상을 쉽게 읽고 이해할 수 있습니다. 예를 들어, 위의 코드에서 --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에서 몇 가지 스타일을 살펴보면서 이러한 패턴을 이해해 보겠습니다.
선언 vs. 폴백 값
과거에는 사용자 정의 프로퍼티를 규칙의 맨 위 또는 :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, 2em); border: var(--btn-border-size, 1px) 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); }
이 방식은 더 엄격하며 모든 기본값과 노출된 변수가 인라인으로 함께 표시됩니다.
사용자 정의 프로퍼티의 활용
사용자 정의 프로퍼티를 어디에 사용할지 결정하는 두 가지 주요 기준이 있습니다:
- 동일한 값을 여러 곳에서 사용해야 할 때(DRY 원칙)
- 값이 변경될 가능성이 있을 때
첫 번째 경우의 좋은 예는 --btn-size
변수입니다. Campfire의 거의 모든 버튼은 내부에 아이콘이 있는 원 모양입니다. 입력 필드와 잘 정렬되도록 하기 위해 아래의 변수를 사용하여 블록 크기를 설정합니다.
: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); }
크기는 :root
레벨에 노출되므로 button
과 input
요소에 사용할 수 있습니다. 또한, 이 값을 사용하여 레이아웃에서 채팅 바닥글의 높이를 계산할 수 있습니다. 이렇게 하면 매직 넘버를 피할 수 있습니다.
* 매직 넘버는 코드에서 특정 의미를 담고 있는 구체적인 숫자 값을 직접 사용하는 것을 말합니다.
베리에이션을 위한 사용자 정의 프로퍼티
사용자 정의 프로퍼티는 요소의 베리에이션을 만들 때 특히 유용합니다. 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 :has()의 활용
CSS의 :has()
선택자는 서버 측 코드에서 수행해야 했던 작업을 CSS로 수행할 수 있는 편리함과 새로운 기회를 제공합니다. Campfire 개발 초기 단계부터 :has()를 사용하기 시작한 이유는 주요 브라우저들이 이 기능을 지원할 것이라는 낙관적인 전망 덕분이었습니다. 실제로 주요 브라우저 중 마지막으로 :has()
를 지원한 Firefox 버전이 출시되기 일주일 전에 Campfire의 첫 번째 베타 버전을 출시했습니다.
:has() 선택자의 기본 개념
:has()
선택자는 요소의 내부에 어떤 요소가 있는지 쿼리할 수 있게 해줍니다. 이는 버튼 클래스를 매우 유연하게 만들며, 그 안에 어떤 조합이든 넣을 수 있습니다. 예를 들어, 텍스트만, 이미지와 텍스트, 이미지만, 입력(예: 라디오 버튼) 또는 텍스트와 여러 이미지가 포함될 수 있습니다.
.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%); } } } }
이 예제에서 .btn
클래스는 그 안에 아바타 사진이 아닌 이미지를 발견하면 특별한 클래스 없이도 크기를 조절하고 어두운 모드에서 반전되도록 합니다. 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
에 원하는 내용을 입력하기만 하면 나머지는 자동으로 처리됩니다.
:has()를 활용한 메뉴 버튼 구현
좁은 뷰포트에서 사이드바를 전환하는 메뉴 버튼을 예로 들어보겠습니다. 모든 대화방을 나열하는 사이드바는 닫혀 있을 때 숨겨지기 때문에 메뉴 버튼에 작은 점을 표시하여 읽지 않은 새 메시지가 있는 대화방이 있음을 표시하고 싶었습니다. 일반적으로는 다음과 같은 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
)를 사용하고 있음을 알 수 있습니다. 이는 조화로우며 매직넘버를 피할 수 있는 방법입니다.
:has()를 활용한 계정 프로필 화면 구현
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의 쪽지 기능인 ‘핑(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; } } }
이와 같이 :has()
선택자를 사용하여 아바타의 수에 따라 레이아웃을 자동으로 조정할 수 있습니다. 참여 인원에 따라 아바타의 크기와 위치가 자동으로 변경되어, 별도의 추가 클래스나 스크립트 없이도 동적인 UI를 구현할 수 있습니다.
이러한 CSS 기술은 특히 사용자의 인터랙션에 따라 UI 요소가 동적으로 변해야 할 때 매우 유용합니다. Campfire의 ‘핑(ping)’ 기능을 통해 이를 쉽게 구현할 수 있음을 확인할 수 있습니다.
반응형 디자인
마지막 섹션에서는 반응형 디자인에 대한 Campfire의 접근 방식을 살펴보겠습니다. 가장 먼저 알아야 할 것은 Campfire에는 @media 쿼리를 기반으로 하는 뷰포트가 전혀 없다는 점입니다. x보다 좁은 뷰포트는 모바일 디바이스라고 단정 짓지 않습니다. Campfire의 레이아웃은 어떤 상태를 “모바일”로 선언하지 않고도 어떤 구성이나 방향으로 어떤 기기를 사용하든 완벽하게 적응합니다. 방법은 다음과 같습니다.
단일 @media 분기점 사용
Campfire에는 여러 곳에서 사용되는 단일 @media
분기점이 있습니다.
@media (max-width: 100ch) { ... }
이 분기점은 주로 뷰포트가 너무 좁아 사이드바를 채팅 내용 옆에 표시할 수 없을 때 CSS 그리드 레이아웃이 어떻게 조정되어야 하는지를 결정합니다. 문서가 100자보다 좁은 경우 나란히 렌더링하는 것은 실용적이지 않으므로, 대신 Campfire는 사이드바를 숨기고 메뉴 버튼을 표시하도록 전환합니다.
문자를 측정 단위로 사용하면 어떤 기기를 사용하든, iPad에서 멀티태스킹을 하거나 글꼴 크기를 특정 지점 이상으로 확대하는 등 다양한 상황에서 올바른 동작을 보장할 수 있습니다. 글꼴은 웹 페이지의 핵심이므로 레이아웃이 이에 반응하는 것이 당연합니다.
기능 개선: 미디어 쿼리 활용
미디어 쿼리를 사용하는 또 다른 목적은 사용자가 어떤 입력 장치를 가지고 있는지에 따라 대응하는 것입니다. 뷰포트가 좁은 디바이스가 반드시 터치스크린이 있다고 가정하거나, 뷰포트가 큰 디바이스에 터치스크린이 없다고 가정하는 것은 결코 옳지 않습니다. 이 모호한 경계는 좀처럼 명확해지지 않고 있습니다. 하지만 @media 쿼리를 통해 디바이스의 기능에 대한 유용한 정보를 얻을 수 있습니다. 먼저, any-hover를 살펴보겠습니다.
@media (any-hover: hover) { &:where(:not(:active):hover) { /* 호버 이펙트 */ } }
이는 사용자의 디바이스에 마우스와 같이 호버링이 가능한 입력 메커니즘이 있는지 쿼리합니다. 터치스크린 기기에서는 적용되지 않으며, 호버 이펙트가 있는 항목을 두 번 탭하게 만드는 모바일 사파리의 성가신 동작을 방지합니다. 나쁘지 않죠.
하지만 좀 더 인상적인 부분을 살펴봅시다. Campfire 채팅의 모든 메시지 라인에는 ••• 버튼이 있어 사용자가 할 수 있는 추가 작업(편집, 부스트, 복사, 공유) 메뉴가 표시됩니다.
마우스나 트랙패드가 있는 디바이스에서는 메시지 위로 마우스를 가져갔을 때만 메뉴를 표시하는 것이 가장 이상적이지만, 그렇게 하면 터치 디바이스에서는 액세스할 수 없게 됩니다. pointer 쿼리와 함께 any-hover를 사용하면 각 종류의 디바이스에서 아무 문제없이 원하는 대로 동작하게 할 수 있습니다.
@media (any-hover: hover) and (pointer: fine) { /* 마우스오버 시에만 버튼 표시 */ } @media (any-hover: none) and (pointer: coarse) { /* 항상 버튼 표시 */ }
iPad Pro와 같은 기기에서는 특히 마법과도 같은 기능입니다. 특정 조건에서 두 쿼리를 모두 매치 할 수 있고 즉각 변경할 수도 있습니다. 트랙패드가 내장된 매직 키보드에 연결하면 첫 번째 쿼리에 매치되고 ••• 버튼은 마우스를 가져갈 때까지 숨겨집니다. 매직 키보드에서 떼어내 순수한 터치 디바이스가 되면 ••• 버튼이 마법처럼 나타납니다.
마치며
Campfire는 CSS의 최신 기능과 패턴을 활용하여 프레임워크나 전처리기 없이 순수 CSS만으로 강력한 웹 애플리케이션을 구축하는 방법을 보여줍니다. 이는 단순히 기술적인 발전을 넘어, 더 나은 사용자 경험과 효율성을 추구하는 개발 철학을 반영합니다. CSS :has()
선택자와 같은 혁신적인 기능을 통해 서버 측 코드의 역할을 대체하고, 다양한 디바이스에 반응하는 유연한 디자인을 구현하는 것이 가능해졌습니다. 이러한 접근법은 웹 개발의 새로운 가능성을 열어주며, 더욱 직관적이고 효율적인 코딩 방식을 제시합니다.
반응형 디자인에 대한 Campfire의 접근 방식은 특정 디바이스나 뷰포트에 구애받지 않고, 모든 환경에서 최적의 사용자 경험을 제공하는 것을 목표로 합니다. @media
쿼리와 사용자 정의 프로퍼티를 활용하여 다양한 입력 장치와 디바이스에 대응하는 Campfire의 기술은 웹 디자인의 새로운 표준을 제시합니다. 이는 사용자에게 최상의 경험을 제공함과 동시에, 개발자의 작업을 단순화하고 효율성을 높이는 데 기여합니다.
앞으로의 웹 개발은 더욱 빠르게 변화할 것입니다. 웹에서 작업하기에 환상적인 시기인 지금, Campfire와 같은 혁신적인 제품은 개발자들에게 새로운 가능성과 영감을 제공할 것입니다. Campfire를 통해 경험할 수 있는 최신 웹 기술의 혜택을 아직 누리지 못했다면, 지금 바로 once.com에서 확인해 보시기 바랍니다.
37signals, “Modern CSS patterns in Campfire”