CSS 상태와 JavaScript 이벤트 사이의 변화하는 경계
요약
CSS 의사 클래스(pseudo-classes)가 JavaScript 이벤트 리스너와 유사한 역할을 수행하며 웹 개발의 경계를 변화시키고 있습니다. `:hover`, `:focus` 등 기존 상태 기반 선택자부터 향후 도입될 `event-trigger` 명세까지 CSS의 진화 방향을 다룹니다.
핵심 포인트
- CSS 의사 클래스는 이벤트가 아닌 상태를 추적하지만 이벤트 리스너처럼 동작할 수 있음
- Animation Triggers 명세의 event-trigger는 이벤트를 감지해 애니메이션을 실행함
- :hover와 :active는 각각 pointerenter/leave 및 pointerdown/up 상태를 포착함
- :focus-visible은 브라우저의 포커스 표시기 필요 여부에 따라 동작하는 정교한 선택자임
CSS는 우리의 말을 듣고 있습니다. 아니, 그런 의미가 아닙니다. 오히려 CSS는 우리가 JavaScript 자체로 대응하지 않아도 되도록 JavaScript 이벤트 (JavaScript events)에 반응할 수 있게 도와주는 의사 클래스 (pseudo-classes)를 점점 더 많이 축적하고 있습니다. 하지만 의사 클래스 (pseudo-classes)는 이벤트 (events)가 아닌 상태 (states)를 추적하는 것이지만, 때로는 이벤트 리스너 (event listeners)처럼 느껴질 수도 있습니다 (CSS의 맥락에서는 그것이 크게 중요하지는 않지만 말입니다).
다시 말해, 요즘의 CSS란 과연 무엇일까요? 예를 들어, Animation Triggers 명세(spec)에는 기본적으로 이벤트를 감지하여 애니메이션을 트리거하는 event-trigger에 대한 제안이 있습니다. 하지만 제 생각에 이 구문은 그 이상의 많은 것을 할 수 있습니다 (CSS를 위한 invoker commands를 생각해 보세요).
하지만 오늘의 현실에 머물러 있기 위해, 현재 지원되지 않는 기능인 event-trigger가 (제 생각에는) 어떻게 작동할지 보여드리기 전에, 이벤트 리스너 (event listeners)와 유사한 기존의 다양한 CSS 의사 클래스 (pseudo-classes)들을 살펴보겠습니다.
“이벤트 리스닝(Event listening)” 의사 클래스 (pseudo-classes)
:hover 및 :active
:hover 상태는 pointerenter 이벤트가 발생하는 시점부터 pointerleave 이벤트가 발생하는 시점까지를 포착하며, 이는 왜 의사 클래스 (pseudo-classes)가 이벤트 (events)가 아닌 상태 (states)인지를 완벽하게 설명해 줍니다.
:active는 마우스, 손가락 또는 스타일러스로 현재 누르고 있는 대상(예: 링크 또는 버튼)과 일치하며, 이는 pointerdown 및 pointerup/pointercancel과 유사합니다.
참고로, pointer-events: none CSS 선언은 선택된 요소에서 포인터 이벤트 (pointer events)가 발생하는 것을 방지합니다!
:focus 및 :focus-visible
:focus 의사 클래스 (pseudo-class)는 JavaScript의 focus 및 blur (unfocus) 이벤트와 유사하지만, :focus-visible은 조금 더 복잡합니다. :focus-visible은 :focus가 트리거될 때 함께 트리거되지만, 추가적으로 브라우저가 포커스 표시기 (focus indicator)를 보여줘야 할지 여부를 결정하기 위해 다양한 휴리스틱 (heuristics)을 사용합니다. 사용자가 키보드로 조작 중인가? 해당 요소가 폼 컨트롤 (form control)인가? 이 점은 CSS가 제공하는 기능에 대해 정말 감탄하게 만듭니다. 실제로 JavaScript를 사용하여 이를 처리하는 가장 좋은 방법은 CSS 의사 클래스 (pseudo-class)를 쿼리하는 것입니다:
element.addEventListener("focus", (event) => {
if (event.target.matches(":focus-visible")) {
/* 무언가를 수행합니다 */
...
:focus-within (및 :has())
JavaScript는 “A가 Y라면, B에 Z를 수행하라”와 같은 종류의 작업에 탁월합니다. 우리는 DOM을 탐색하고, 이벤트 전파 (event propagation)를 활용하는 등 훨씬 더 많은 일을 할 수 있습니다. 그런 관점에서 CSS는 다소 제한적으로 느껴질 수 있습니다. 하지만 CSS는 빠르게 진화하고 있습니다. 스크롤 기반 애니메이션 (scroll-driven animations)과 같은 많은 새로운 "if-this-do-that" 기능들을 갖추고 있으며, 앞으로 더 많아질 것입니다. HTML 또한 <details>와 같이 전용 컴포넌트를 통해 동일한 작업을 수행하고 있으며, 이들은 모두 수반되는 CSS 기능들을 가지고 있습니다.
사실, 이에 대해서는 나중에 더 언급하겠습니다. 더 총체적인 관점에서 우리가 가진 것은 자식 요소가 포커스를 가지고 있으면 매칭되는 :focus-within과, 임의의 유효한 선택자 (selector)를 허용하며 두 선택자 사이에 그러한 관계가 존재하면 매칭되는 :has()입니다.
예를 들어, 다음 두 선택자는 정확히 동일한 작업을 수행합니다:
form:focus-within {
/* 내부의 무언가가 포커스를 가질 때 form에 스타일을 적용합니다 */
}
...
:checked
:checked
[:checked](https://css-tricks.com/almanac/pseudo-selectors/c/checked/)가 무엇을 하는지는 상당히 명백합니다. 이와 가장 동의어에 가까운 JavaScript 이벤트는 change이며, 이는 <input>, <select>, 또는 <textarea>의 값이 변경될 때 발생합니다 (비록 이 문맥에서는 input 이벤트도 매우 유사하지만 말입니다).
체크 상태를 감지하려면 다음과 같이 작성할 수 있습니다:
checkbox.addEventListener("change", (event) => {
if (event.target.checked) {
/* Checked */
...
CSS 의사 클래스 (pseudo-classes)는 종종 두 JavaScript 이벤트 사이의 순간(예: pointerenter와 pointerleave)을 포착하지만, 그렇지 않을 때는 위와 같이 로직을 처리하기도 합니다.
숨겨진 로직 처리의 몇 가지 예를 더 살펴보겠습니다.
:valid/:invalid/:user-valid/:user-invalid/:autofill
여기서는 [:not()](https://css-tricks.com/almanac/pseudo-selectors/n/not/) 의사 클래스 함수를 사용할 필요가 없습니다. 유효성(validity)은 [:valid](https://css-tricks.com/almanac/pseudo-selectors/v/valid/)와 [:invalid](https://css-tricks.com/almanac/pseudo-selectors/i/invalid/) 의사 클래스를 모두 사용하여 확인할 수 있기 때문입니다. 하지만 JavaScript 측면에서는 valid 이벤트가 없으며 (invalid 이벤트만 존재합니다), 그렇다 하더라도 JavaScript를 사용한다면 input, change, blur (요소에서 포커스를 해제할 때 유효성을 확인하기 위해), 또는 submit (아래와 같이 폼을 제출할 때 전체 폼의 유효성을 확인하기 위해) 이벤트 리스너의 콜백 함수 내에서 checkValidity() 메서드를 호출하고 싶을 것입니다 (이 메서드는 실제로 false를 반환하면 invalid 이벤트를 발생시킵니다).
form.addEventListener("submit", () => {
if (form.checkValidity()) {
/* All form controls are valid */
...
또한 [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) 객체를 통해서도 이를 수행할 수 있습니다. 이 객체는 invalid 이벤트를 발생시키지는 않지만, HTML 폼 유효성 검사(form validation)와 동일한 방식으로 폼 컨트롤이 왜 유효하거나 유효하지 않은지를 알려줍니다:
input.addEventListener("input", () => {
if (input.validity.valid) {
/* Input is valid */
...
HTML 폼 유효성 검사 (form validation)의 특징은 프론트엔드 전체를 알아서 처리해 준다는 점입니다. 하지만 기본값이 아닌 별도의 동작이 필요하다면, checkValidity() 또는 ValidityState가 여러분이 찾는 해결책이 될 것입니다.
의사 클래스 (pseudo-classes)는 어떤 방식이든 작동합니다. 사실, 너무 잘 작동할 정도입니다! 놓치기 쉬운 점은 폼 컨트롤이 :valid 또는 :invalid를 즉시 트리거한다는 것입니다. 반면, :user-valid와 :user-invalid는 사용자가 값을 입력하고 포커스를 해제(unfocus)할 때까지 기다렸다가 트리거됩니다. 이것이 실제로 change 이벤트가 작동하는 방식이며 (요소가 체크박스, 라디오 버튼, 드롭다운 리스트, 컬러 피커 또는 레인지 슬라이더가 아닌 경우), input 이벤트와 차이점을 만드는 지점입니다.
자동 완성 (auto-filling)을 위한 JavaScript 이벤트는 없으며, JavaScript를 사용하여 이를 감지하는 깔끔한 방법도 없지만, :autofill이라는 의사 클래스는 존재합니다.
미디어 요소 의사 클래스 (Media element pseudo-classes)
미디어 요소 의사 클래스는 아직 새로운 기능입니다. 아직 Chrome에서는 지원되지 않으며 최근 Firefox에 도입되었지만, Interop 2026의 일부이며 곧 우리는 JavaScript 이벤트를 감지하지 않고도 상태에 따라 <audio> 및 <video> 요소를 스타일링할 수 있게 될 것입니다. 여러분은 이제 이것이 어떻게 작동하는지 이해하셨을 것이라 믿습니다. 간단히 요약하자면 다음과 같습니다:
| 의사 클래스 (Pseudo-class) | JavaScript 이벤트 대응물 |
|---|---|
:buffering | waiting |
| ... |
음소거를 감지하려면 volumechange 이벤트를 사용하세요:
audio.addEventListener("volumechange", () => {
if (audio.muted) {
// Muted
...
볼륨 잠금 (volume lock)을 감지한다는 것은 볼륨 변경을 시도하고 성공 여부를 확인하는 것을 의미합니다. 가장 좋은 접근 방식은 실제 요소에서 volumechange가 트리거되지 않도록 완전히 새로운 요소를 생성하는 것입니다:
// 비디오 생성
const video = document.createElement("video");
...
(또는 CSS를 작성 중이라면 :volume-locked 의사 클래스를 사용하세요.)
:popover-open / :open / :modal
예상할 수 있듯이, popover, <dialog>, 또는 <details>가 열리거나 닫힐 때 발생하는 JavaScript 이벤트는 없지만, toggle 이벤트를 감지한 후 상태를 확인하는 방식으로 처리할 수 있습니다:
element.addEventListener("toggle", () => {
if (element.open) {
/* Popover/dialog/details open */
...
하지만 CSS는 다음과 같은 의사 클래스 (pseudo-classes)를 즉시 제공합니다:
[:popover-open](https://css-tricks.com/almanac/pseudo-selectors/p/popover-open/)"(popover용)[:open](https://css-tricks.com/almanac/pseudo-selectors/o/open/)(<dialog>및<details>요소용):modal(모달<dialog>및 전체 화면 요소용)
전체 화면 요소에 대해 이야기하자면…
:fullscreen
:fullscreen 의사 클래스는 조건문이 내장된 fullscreenchange JavaScript 이벤트와 동의어입니다:
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement) {
/* fullscreenElement is fullscreen */
...
:target
URL 해시 (예: #contact)가 요소의 ID (예: <div id="contact">)와 일치하면, 해당 요소는 [:target](https://css-tricks.com/almanac/pseudo-selectors/t/target/) 의사 클래스와 일치하게 됩니다. JavaScript를 사용할 때는 hashchange 이벤트를 감지한 후 일치하는 요소를 찾는지 확인해야 합니다:
window.addEventListener("hashchange", () => {
const target = document.getElementById(window.location.hash.substring(1));
...
결론 (하지만 진짜 결론은 아닙니다)
이 글은 "JavaScript는 나쁘다"라고 주장하는 비난이 아니라, JavaScript가 제공하는 정밀한 제어 능력을 잊지 않으면서도 CSS가 얼마나 많은 것을 단순화해 주는지에 대한 찬사입니다. 무언가를 수행할 수 있는 방법이 더 많아지는 것은 결코 나쁜 일이 아닙니다.
그리고 그 점에 착안하여, event-trigger에 대해 빠르게 언급하고 싶습니다.
실제 이벤트 리스너 (event-trigger)
Chrome이 스크롤 트리거 애니메이션 (scroll-triggered animations)을 구현할 때 이벤트 트리거 (event triggers)를 접하게 되었습니다. 같은 모듈에 포함되어 있지만, 아직 어떤 웹 브라우저에서도 지원되지 않으므로 제가 실수하더라도 양해 부탁드립니다. 그럼 자세히 살펴보겠습니다.
event-trigger-name은 단순한 대시 형태의 식별자 (dashed ident)를 허용합니다:
button {
event-trigger-name: --event;
}
event-trigger-source는 본질적으로 이벤트 리스너 (event listener) 역할을 하게 됩니다.
다음 키워드들을 허용합니다:
activateinterestclicktouchdblclickkeypress(<string>)
button {
event-trigger-source: click;
}
interest 키워드는 곧 출시될 Interest Invoker API를 지칭하는 것으로 보이며, activate 키워드는 요소에 따라 달라질 수 있을 것 같습니다. 예를 들어 <details> 요소의 경우, 활성화 (activation)는 _열렸을 때_를 의미할 수 있지만 확실하지는 않습니다. 명세 (spec)의 후속 초안(drafts)이 더 많은 정보를 제공하고 더 많은 이벤트를 공개해 줄 것입니다.
어쨌든, 이 이벤트들은 애니메이션을 트리거할 것입니다. 먼저 @keyframes 애니메이션을 생성한 다음, 애니메이션을 적용할 요소에 연결하지만, 이벤트에 의해 트리거되기 전까지는 애니메이션이 실행되지 않습니다 (일반적으로는 즉시 실행되는 것과 대조적입니다).
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
...
그 다음, 이벤트가 발생했을 때 애니메이션이 트리거되도록 설정합니다. 이를 위해 animation과 함께 animation-trigger를 설정하고, 대시 형태의 식별자 (--event)를 참조합니다. 이렇게 하면 한 요소의 이벤트가 다른 요소의 애니메이션을 트리거할 수 있다는 선택적인 이점도 있습니다. 이번에는 event-trigger 축약형 (shorthand)을 사용한 간단한 예시입니다:
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
...
이것이 바로 state_less_ (상태가 없는) 이벤트 트리거라고 불리는 것입니다. 한번 생각해 보세요. 클릭한 것을 '클릭하지 않은 상태'로 되돌릴 수는 없지 않습니까? 하지만 우리는 흥미를 잃을 수는 있습니다. 그렇다면 state_full_ (상태가 있는) 이벤트 트리거 기반 애니메이션은 다음과 같은 모습일 것입니다 ( /로 구분된 두 개의 이벤트와, 각 상태에 대응하는 두 개의 애니메이션 액션 구문을 주목하세요):
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
...
사용 가능한 애니메이션 액션 (animation actions)은 다음과 같습니다:
noneplayplay-onceplay-forwardsplay-backwardspauseresetreplay
이벤트와 애니메이션 액션의 조합 중 작동하지 않는 조합이 많이 있겠지만, 논리적으로 맞지 않는 조합들이기에 쉽게 피할 수 있을 것입니다. 하지만 animation-trigger는 animation의 리셋 전용 (reset-only) 하위 속성 (sub-property)이기 때문에, 우리는 여러 개의 서로 다른 애니메이션을 트리거할 수 있습니다. 대략적인 예시는 다음과 같습니다:
animation-name: animationA, animationB;
animation-trigger: --eventA play, --eventB replay;
AI 자동 생성 콘텐츠
본 콘텐츠는 CSS-Tricks의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기