뒤로가기
JavaScript 없이 CSS만으로 되는 것들

January 1, 2021

CSSfrontendproductivity

작년에 캐러셀 컴포넌트를 만들었다.

Swiper.js를 설치하고, 옵션을 맞추고, 리사이즈 이벤트 리스너를 달고, 클린업 함수를 작성했다. 번들 사이즈가 38KB 늘었다. PR 리뷰에서 동료가 한마디 했다. "이거 CSS scroll-snap으로 안 돼?" 됐다. JS 0줄에, 라이브러리 설치도 필요 없었다. 그 38KB와 내가 쓴 60줄의 코드가 CSS 10줄로 대체되는 걸 보면서, 내가 얼마나 관성적으로 JavaScript부터 꺼내 드는지 깨달았다.

Ahmad Shadeed의 ishadeed.com과 Stephanie Eckles의 moderncss.dev를 뒤지다 보면, 요즘 CSS가 할 수 있는 게 정말 많아졌다는 걸 체감한다. 문제는 우리가 그걸 모른다는 게 아니라, 습관적으로 npm install부터 친다는 거다.

여기 정리한 6가지는 전부 내가 실제 프로젝트에서 JS를 걷어내고 CSS로 바꾼 것들이다.

Scroll Snap: 캐러셀 라이브러리 이제 그만#

위에서 말한 그 캐러셀 이야기다. 전형적인 가로 스크롤 캐러셀을 만든다고 해보자.

JS로 하면 보통 이렇게 된다:

tsx
import { useRef, useEffect } from 'react';

function Carousel({ items }) {
  const containerRef = useRef(null);

  const scrollTo = (index) => {
    const container = containerRef.current;
    const child = container.children[index];
    child.scrollIntoView({ behavior: 'smooth', inline: 'start' });
  };

  useEffect(() => {
    const container = containerRef.current;
    // 리사이즈 대응
    const handleResize = () => {
      // 현재 위치 재계산...
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div ref={containerRef} style={{ display: 'flex', overflow: 'auto' }}>
      {items.map(item => <Card key={item.id} {...item} />)}
    </div>
  );
}

이걸 CSS scroll-snap으로 바꾸면:

css
.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  gap: 16px;
}

.carousel > * {
  scroll-snap-align: start;
  flex-shrink: 0;
  width: 300px;
}
tsx
function Carousel({ items }) {
  return (
    <div className="carousel">
      {items.map(item => <Card key={item.id} {...item} />)}
    </div>
  );
}

리사이즈 핸들러도 필요 없고, scrollIntoView 계산도 필요 없다. 브라우저가 알아서 가장 가까운 snap point로 스크롤을 잡아준다. scroll-snap-type: x mandatory가 핵심인데, mandatory는 스크롤이 끝나면 반드시 snap point에 정렬되도록 강제한다. proximity로 바꾸면 좀 더 부드럽게 동작한다.

내가 만들었던 제품 상세 페이지의 이미지 뷰어에서 Swiper.js를 걷어내고 이걸로 대체했을 때, 번들 사이즈가 눈에 띄게 줄었고 성능 점수도 올라갔다. 모바일에서의 터치 스크롤 느낌도 오히려 더 자연스러웠다.

:has() — 드디어 부모를 볼 수 있다#

CSS에서 가장 오래된 불만 중 하나가 "부모 선택자가 없다"는 거였다. 자식 상태에 따라 부모 스타일을 바꾸려면 항상 JS가 필요했다.

예를 들어 폼에서 input이 focus되면 감싸고 있는 div의 테두리 색을 바꾸고 싶다고 하자.

JS 시절:

tsx
function InputGroup() {
  const [focused, setFocused] = useState(false);

  return (
    <div className={`input-group ${focused ? 'focused' : ''}`}>
      <label>이메일</label>
      <input
        type="email"
        onFocus={()=> setFocused(true)}
        onBlur={()=> setFocused(false)}
      />
    </div>
  );
}
css
.input-group {
  border: 2px solid #ddd;
  border-radius: 8px;
  padding: 8px 12px;
}

.input-group.focused {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

state 하나, 이벤트 핸들러 둘, 클래스 토글 하나. 이걸 위해서?

CSS :has()로 바꾸면:

css
.input-group {
  border: 2px solid #ddd;
  border-radius: 8px;
  padding: 8px 12px;
}

.input-group:has(input:focus) {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
tsx
function InputGroup() {
  return (
    <div className="input-group">
      <label>이메일</label>
      <input type="email" />
    </div>
  );
}

state가 사라졌다. 컴포넌트가 순수해졌다. :has()는 "이 요소의 자손 중에 조건에 맞는 게 있으면"이라는 뜻이다. 부모 선택자라고 불리지만, 사실은 관계 선택자에 가깝다.

실무에서 이게 진짜 빛나는 건 카드 컴포넌트다. 카드 안에 이미지가 있으면 패딩을 없애고, 없으면 패딩을 넣는 식으로:

css
.card {
  padding: 24px;
  border-radius: 12px;
  background: white;
}

.card:has(> img:first-child) {
  padding: 0;
  overflow: hidden;
}

.card:has(> img:first-child) > :not(img) {
  padding: 0 24px;
}

이전에는 이 로직을 컴포넌트에서 props로 분기했다. variant="with-image" 같은. 지금은 CSS가 콘텐츠를 보고 알아서 판단한다.

2025년 기준으로 모든 주요 브라우저가 :has()를 지원한다. Can I Use에서 확인해보면 글로벌 지원율이 90%를 넘는다.

Container Queries: 뷰포트가 아니라 부모가 기준이다#

media query의 한계는 명확하다. 뷰포트 기준이다. 사이드바에 들어간 카드 컴포넌트는 화면이 1200px여도 실제로는 300px 안에서 살고 있는데, media query는 그걸 모른다.

이걸 해결하려면 결국 JS로 부모의 크기를 측정해야 했다. ResizeObserver를 쓰거나, 라이브러리를 가져오거나.

tsx
function Card() {
  const ref = useRef(null);
  const [isCompact, setIsCompact] = useState(false);

  useEffect(() => {
    const observer = new ResizeObserver(([entry]) => {
      setIsCompact(entry.contentRect.width < 400);
    });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref} className={isCompact ? 'card compact' : 'card'}>
      <img src="/thumb.jpg" alt="" />
      <h3>제목</h3>
      <p>설명 텍스트</p>
    </div>
  );
}

이제 container queries로:

css
.card-wrapper {
  container-type: inline-size;
}

.card {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 16px;
}

@container (max-width: 400px) {
  .card {
    grid-template-columns: 1fr;
  }

  .card img {
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
}

container-type: inline-size를 부모에 선언하면, 그 안의 자식들이 @container 쿼리를 쓸 수 있다. 뷰포트가 아니라 부모 컨테이너의 크기를 기준으로 반응한다.

이게 진짜 중요한 이유가 있다. 컴포넌트를 재사용 가능하게 만들려면, 그 컴포넌트가 어디에 배치되든 알아서 적응해야 한다. 메인 콘텐츠 영역에서도, 사이드바에서도, 모달 안에서도. media query로는 이걸 할 수가 없었다. Container queries는 진짜 의미에서의 반응형 컴포넌트를 가능하게 한다.

CSS Nesting: Sass 없이도 된다#

짧게 가자.

CSS nesting이 네이티브로 들어왔다. Sass를 쓰는 주된 이유 중 하나가 사라진 거다.

css
/* 예전: Sass 없이 CSS */
.nav { display: flex; gap: 8px; }
.nav a { color: #333; text-decoration: none; }
.nav a:hover { color: #3b82f6; }
.nav a.active { font-weight: 700; color: #3b82f6; }

/* 지금: 네이티브 CSS nesting */
.nav {
  display: flex;
  gap: 8px;

  a {
    color: #333;
    text-decoration: none;

    &:hover {
      color: #3b82f6;
    }

    &.active {
      font-weight: 700;
      color: #3b82f6;
    }
  }
}

완전히 같은 문법이다. &도 된다. media query도 안에 넣을 수 있다:

css
.hero {
  padding: 64px 24px;
  font-size: 48px;

  @media (max-width: 768px) {
    padding: 32px 16px;
    font-size: 28px;
  }
}

Sass 설정, 빌드 파이프라인, 의존성 관리. 이런 거 없이 그냥 .css 파일에 쓰면 된다. 프로젝트 초기 셋업이 한 단계 단순해진다.

한 가지 주의할 점이 있는데, nesting이 너무 깊어지면 Sass 때와 똑같은 문제가 생긴다. specificity가 올라가고 코드가 읽기 어려워진다. 3단계까지만 쓰자는 규칙은 여전히 유효하다.

accent-color: 폼 요소 커스텀의 고통이 끝났다#

checkbox, radio, range input의 색상을 브랜드 컬러로 바꾸려면 얼마나 고통스러웠는지 다들 기억할 거다.

기본 checkbox를 숨기고, 가짜 checkbox를 만들고, 클릭 이벤트를 연결하고, 접근성 속성을 하나하나 달아주고. 이 과정이 너무 싫어서 아예 Headless UI 같은 라이브러리를 쓰기도 했다.

css
/* 예전의 커스텀 checkbox... 이런 걸 했었다 */
input[type="checkbox"] {
  appearance: none;
  width: 20px;
  height: 20px;
  border: 2px solid #ccc;
  border-radius: 4px;
  position: relative;
  cursor: pointer;
}

input[type="checkbox"]:checked {
  background: #3b82f6;
  border-color: #3b82f6;
}

input[type="checkbox"]:checked::after {
  content: '';
  position: absolute;
  left: 5px;
  top: 1px;
  width: 6px;
  height: 12px;
  border: solid white;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

체크 표시를 ::after로 그리고 있다. CSS로 체크 모양을 만드는 거다. 여기에 focus 스타일, disabled 스타일까지 추가하면 checkbox 하나에 CSS가 30줄이 넘는다.

accent-color 한 줄이면 된다:

css
input[type="checkbox"],
input[type="radio"],
input[type="range"],
progress {
  accent-color: #3b82f6;
}

끝이다. 브라우저 기본 UI를 유지하면서 색상만 바꾼다. 접근성도 그대로고, focus ring도 그대로다. 브랜드 컬러만 입히면 되는 상황이라면 이걸로 충분하다.

물론 디자이너가 완전히 새로운 형태의 checkbox를 원한다면 accent-color로는 안 된다. 근데 "색상만 바꿔주세요"라는 요청이 체감상 80%다. 그 80%를 한 줄로 처리할 수 있다는 게 핵심이다.

text-wrap: balance — 제목 줄바꿈의 미학#

이건 기능적인 문제라기보다 디자인 품질의 문제다.

긴 제목이 두 줄로 넘어갈 때, 첫 줄은 빽빽하고 둘째 줄에 단어 하나만 덩그러니 남는 경우가 있다. 영어에서는 이걸 "widow"라고 부른다. 한국어에서도 마찬가지로 보기 안 좋다.

text
JavaScript 없이 CSS만으로 할 수 있는 놀라운
것들                    ← 이런 거

이걸 고치려고 JS로 글자 수를 세거나, <br>을 수동으로 넣거나, 혹은 그냥 무시했다.

css
h1, h2, h3 {
  text-wrap: balance;
}

이 한 줄이면 브라우저가 알아서 두 줄의 길이를 비슷하게 맞춰준다.

text
JavaScript 없이 CSS만으로
할 수 있는 놀라운 것들      ← 이렇게 됨

주의할 점이 있다. text-wrap: balance는 성능상의 이유로 6줄 이하의 텍스트에서만 동작한다. 그래서 본문에 쓰는 건 의미가 없고, 제목이나 캡션 같은 짧은 텍스트에 쓰는 거다.

긴 본문에서 마지막 줄이 너무 짧아지는 걸 방지하고 싶으면 text-wrap: pretty를 쓴다. balance보다 가볍고, 마지막 줄만 신경 쓴다.

css
p {
  text-wrap: pretty;
}

디자이너가 "이 제목 줄바꿈 좀 이상한데" 하고 슬랙에 메시지를 보내기 전에, 글로벌 스타일에 저 한 줄을 넣어두자.

언제 JS를 쓰고 언제 CSS를 쓸까#

여기까지 읽으면 "그럼 JS는 다 필요 없는 건가" 싶을 수 있는데, 당연히 아니다.

CSS가 잘하는 건 선언적인 상태 변화다. hover, focus, 스크롤 위치, 부모 크기, 콘텐츠 유무. 이런 건 CSS가 브라우저 레벨에서 처리하니까 JS보다 빠르고 안정적이다.

JS가 필요한 건 외부 데이터가 개입하는 경우다. API 응답에 따라 UI가 바뀌어야 하거나, 유저 인터랙션의 히스토리를 추적해야 하거나, 타이머나 애니메이션 시퀀스를 세밀하게 제어해야 하는 경우.

내가 쓰는 기준은 단순하다. "이 동작이 CSS 속성의 변화만으로 표현 가능한가?" 그렇다면 CSS를 먼저 시도한다. 안 되면 그때 JS를 쓴다. 순서가 중요하다. JS부터 생각하면 CSS로 되는 것도 JS로 하게 된다.

다음 프로젝트에서 이 중 하나라도 써보면, 코드가 얼마나 단순해지는지 직접 느낄 수 있을 거다. 번들 사이즈가 줄고, state가 줄고, 버그가 줄어든다. 그리고 가끔은 그게 가장 큰 비즈니스 임팩트다.