본문으로 이동
·20분 읽기

2026년 모던 CSS: 스타일 작성 방식을 바꾼 기능들

컨테이너 쿼리, CSS 레이어, :has(), color-mix(), 네스팅, 스크롤 기반 애니메이션, 앵커 포지셔닝. JavaScript 없이도 가능하게 만들어준 CSS 기능들.

공유:X / TwitterLinkedIn

6개월 전에 마지막 Sass 파일을 삭제했습니다. 선언을 하기 위해서가 아닙니다. 진짜로 더 이상 필요 없었기 때문입니다.

10년 넘게 CSS는 사과하며 사용하는 언어였습니다. 네스팅과 변수를 위해 전처리기가 필요했습니다. 컨테이너 기반 크기 조정, 스크롤 연동 애니메이션, 부모 선택, 디자이너가 건네주는 레이아웃 패턴의 절반을 위해 JavaScript가 필요했습니다. CSS-in-JS 라이브러리, 유틸리티 프레임워크, PostCSS 플러그인 체인 등 — 언어가 네이티브로 할 수 없는 것을 보완하기 위한 전체 런타임 시스템을 구축했습니다.

그 시대는 끝났습니다. "거의 끝났다"가 아닙니다. "가고 있는 중"이 아닙니다. 끝났습니다.

2024년에서 2026년 사이에 브라우저에 도입된 기능들은 단순히 편의를 더한 것이 아닙니다. 정신 모델을 바꿨습니다. CSS는 더 이상 싸워야 하는 스타일링 언어가 아닙니다. 컴포넌트, 명시도 관리, 요소 수준의 반응형 디자인, 런타임 없는 애니메이션을 실제로 고려하는 스타일링 언어입니다.

무엇이 바뀌었는지, 왜 중요한지, 그리고 무엇을 이제 그만 사용해도 되는지 알아봅시다.

컨테이너 쿼리: "뷰포트 너비가 얼마야?"의 종말#

반응형 디자인에 대한 사고방식을 근본적으로 바꾼 기능입니다. 20년 동안 미디어 쿼리가 있었습니다. 미디어 쿼리는 "뷰포트가 얼마나 넓은가?"를 묻습니다. 컨테이너 쿼리는 "이 컴포넌트가 들어있는 컨테이너가 얼마나 넓은가?"를 묻습니다.

그 차이가 미묘해 보입니다. 하지만 아닙니다. 하나의 레이아웃 컨텍스트에서만 작동하는 컴포넌트와 어디서나 작동하는 컴포넌트의 차이입니다.

20년간 존재했던 문제#

카드 컴포넌트를 생각해 보세요. 사이드바에서는 작은 이미지와 함께 수직으로 쌓여야 합니다. 메인 콘텐츠 영역에서는 더 큰 이미지와 함께 수평이어야 합니다. 전체 너비 히어로 섹션에서는 또 다른 모습이어야 합니다.

미디어 쿼리로는 이런 식으로 작성했을 것입니다:

css
/* The old way: coupling component styles to page layout */
.card {
  display: flex;
  flex-direction: column;
}
 
@media (min-width: 768px) {
  .sidebar .card {
    /* Still vertical in sidebar */
  }
 
  .main-content .card {
    flex-direction: row;
    /* Horizontal in main area */
  }
}
 
@media (min-width: 1200px) {
  .hero .card {
    /* Yet another layout */
  }
}

카드가 .sidebar, .main-content, .hero에 대해 알고 있습니다. 페이지에 대해 알고 있습니다. 더 이상 컴포넌트가 아닙니다 — 페이지를 인식하는 조각입니다. 다른 페이지로 옮기면 모든 것이 깨집니다.

컨테이너 쿼리가 이것을 완전히 해결합니다#

css
/* The container query way: component knows only about itself */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}
 
.card {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}
 
@container card (min-width: 400px) {
  .card {
    grid-template-columns: 200px 1fr;
  }
}
 
@container card (min-width: 700px) {
  .card {
    grid-template-columns: 300px 1fr;
    gap: 2rem;
    font-size: 1.125rem;
  }
}

카드는 자신이 어디에 있는지 모릅니다. 신경 쓰지 않습니다. 300px 사이드바에 넣으면 수직이 됩니다. 700px 메인 영역에 넣으면 수평이 됩니다. 전체 너비 섹션에 놓으면 적응합니다. 페이지 레이아웃에 대한 지식이 전혀 필요 없습니다.

inline-size vs size#

거의 항상 container-type: inline-size를 원할 것입니다. 이것은 인라인 축 (가로 쓰기 모드에서 너비)에 대한 쿼리를 활성화합니다. container-type: size를 사용하면 인라인과 블록 축 쿼리 모두 활성화되지만, 두 차원 모두에서 명시적 크기가 필요하며, 이는 대부분의 경우 정상적인 문서 흐름을 깨뜨립니다.

css
/* This is what you want 99% of the time */
.wrapper {
  container-type: inline-size;
}
 
/* This requires explicit height — rarely what you want */
.wrapper-both {
  container-type: size;
  height: 500px; /* required, or it collapses */
}

중첩 컨텍스트를 위한 이름 있는 컨테이너#

컨테이너를 중첩할 때 이름 지정이 필수적입니다:

css
.page-layout {
  container-type: inline-size;
  container-name: layout;
}
 
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* Target the sidebar specifically, not the nearest ancestor */
@container sidebar (max-width: 250px) {
  .nav-item {
    font-size: 0.875rem;
    padding: 0.25rem;
  }
}
 
/* Target the page layout */
@container layout (min-width: 1200px) {
  .page-header {
    font-size: 2.5rem;
  }
}

이름 없이 @container는 포함을 가진 가장 가까운 조상을 쿼리합니다. 중첩이 있으면 원하는 컨테이너가 아닌 경우가 많습니다. 이름을 지정하세요. 항상.

컨테이너 쿼리 단위#

이것은 과소평가되고 있습니다. 컨테이너 쿼리 단위 (cqi, cqb, cqw, cqh)를 사용하면 뷰포트가 아닌 컨테이너에 상대적으로 크기를 조정할 수 있습니다:

css
@container (min-width: 400px) {
  .card-title {
    font-size: clamp(1rem, 4cqi, 2rem);
  }
}

4cqi는 컨테이너 인라인 크기의 4%입니다. 제목이 창이 아닌 컨테이너에 맞게 조정됩니다. 이것이 처음부터 유동 타이포그래피가 되어야 했던 모습입니다.

CSS 레이어: 명시도 전쟁의 종말#

컨테이너 쿼리가 반응형 디자인에 대한 사고방식을 바꿨다면, @layer는 CSS 아키텍처에 대한 사고방식을 바꿨습니다. 처음으로 전체 프로젝트에 걸쳐 명시도를 관리하는 합리적이고 선언적인 방법이 생겼습니다.

문제#

CSS 명시도는 의도를 신경 쓰지 않는 포인트 시스템입니다. .text-red라는 유틸리티 클래스는 .card .title에 지게 됩니다. 후자의 명시도가 더 높기 때문입니다. 해결책은 항상 같았습니다: 선택자를 더 구체적으로 만들거나, !important를 추가하거나, 모든 것을 재구성하는 것.

명시도 충돌을 피하기 위해 전체 방법론 (BEM, SMACSS, ITCSS)과 도구 체인을 구축했습니다. 그 모든 것이 누락된 언어 기능에 대한 해결법이었습니다.

레이어 순서#

@layer를 사용하면 그룹 내의 명시도와 관계없이 스타일 그룹이 고려되는 순서를 선언할 수 있습니다:

css
/* Declare layer order — this single line controls everything */
@layer reset, base, components, utilities, overrides;
 
@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}
 
@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
    color: oklch(20% 0 0);
  }
 
  h1, h2, h3 {
    line-height: 1.2;
    text-wrap: balance;
  }
}
 
@layer components {
  .card {
    background: white;
    border-radius: 0.5rem;
    padding: 1.5rem;
    box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
  }
 
  .card .title {
    font-size: 1.25rem;
    font-weight: 600;
    color: oklch(25% 0.05 260);
  }
}
 
@layer utilities {
  .text-red {
    color: oklch(55% 0.25 25);
  }
 
  .hidden {
    display: none;
  }
 
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
  }
}

.card .title.text-red보다 더 높은 명시도를 가지고 있지만, utilities 레이어가 components 이후에 선언되었기 때문에 유틸리티가 이깁니다. !important도 없고. 명시도 꼼수도 없습니다. 레이어 순서가 최종 결정입니다.

Tailwind CSS v4가 레이어를 사용하는 방법#

Tailwind v4는 @layer에 크게 의존합니다. @import "tailwindcss"를 작성하면 다음을 얻습니다:

css
@layer theme, base, components, utilities;

모든 Tailwind 유틸리티는 utilities 레이어에 있습니다. 커스텀 컴포넌트 스타일은 components에 넣습니다. 이것이 text-red-500 클래스가 !important 없이도 컴포넌트의 색상을 오버라이드할 수 있는 이유입니다 — 더 나중 레이어에 있기 때문입니다.

Tailwind 없이 자체 디자인 시스템을 구축한다면 이 아키텍처를 가져다 쓰세요. 이것이 올바른 방법입니다:

css
@layer reset, tokens, base, layouts, components, utilities, overrides;

7개 레이어면 충분합니다. 그 이상 필요했던 적이 없습니다.

레이어에 속하지 않은 스타일이 모든 것을 이깁니다#

주의할 점: 어떤 레이어에도 속하지 않은 스타일이 가장 높은 우선순위를 가집니다. 이것은 사실 유용합니다 — 일회성 페이지별 오버라이드가 자동으로 이긴다는 의미입니다:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Not in any layer — wins over everything in layers */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

하지만 레이어를 인식하지 못하는 서드파티 CSS가 전체 시스템을 오버라이드할 수도 있습니다. 서드파티 스타일을 레이어로 감싸서 제어하세요:

css
@layer third-party {
  @import url("some-library.css");
}

:has() 선택자: 우리가 항상 원했던 부모 선택자#

말 그대로 수십 년 동안 개발자들은 부모 선택자를 요청했습니다. "자식에 따라 부모 스타일을 지정하고 싶습니다." 답은 항상 "CSS는 그걸 못 합니다"였고 JavaScript 해결법이 뒤따랐습니다. :has()는 이것을 완전히 바꿨고, 요청했던 것보다 더 강력한 것으로 밝혀졌습니다.

기본 부모 선택#

css
/* Style a form group when its input has focus */
.form-group:has(input:focus) {
  border-color: oklch(55% 0.2 260);
  box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
 
/* Style a card differently when it contains an image */
.card:has(img) {
  padding-top: 0;
}
 
.card:has(img) .card-content {
  padding: 1.5rem;
}
 
/* A card without an image gets different treatment */
.card:not(:has(img)) {
  border-left: 4px solid oklch(55% 0.2 260);
}

JavaScript 없는 폼 유효성 검사 상태#

여기서 :has()가 정말 흥미로워집니다. HTML 유효성 검사 의사 클래스와 결합하면 JavaScript 없이 유효성 상태에 반응하는 폼 UI를 구축할 수 있습니다:

css
/* The field wrapper reacts to its input's validity */
.field:has(input:invalid:not(:placeholder-shown)) {
  --field-color: oklch(55% 0.25 25);
}
 
.field:has(input:valid:not(:placeholder-shown)) {
  --field-color: oklch(55% 0.2 145);
}
 
.field {
  --field-color: oklch(70% 0 0);
  border: 2px solid var(--field-color);
  border-radius: 0.5rem;
  padding: 0.75rem;
  transition: border-color 0.2s;
}
 
.field label {
  color: var(--field-color);
  font-size: 0.875rem;
  font-weight: 500;
}
 
.field .error-message {
  display: none;
  color: oklch(55% 0.25 25);
  font-size: 0.8rem;
  margin-top: 0.25rem;
}
 
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
  display: block;
}

:not(:placeholder-shown) 부분이 중요합니다 — 아직 건드리지 않은 빈 필드에 유효성 검사 스타일이 나타나는 것을 방지합니다.

자식 수에 따른 레이아웃#

이 패턴은 터무니없이 유용하며 :has() 이전에는 정말로 불가능했습니다:

css
/* Adjust grid columns based on how many items exist */
.grid-auto:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr);
}
 
.grid-auto:has(> :nth-child(7)) {
  grid-template-columns: repeat(3, 1fr);
}
 
.grid-auto:has(> :nth-child(13)) {
  grid-template-columns: repeat(4, 1fr);
}

그리드가 가진 자식 수에 따라 열 수를 변경합니다. JavaScript 없음. ResizeObserver 없음. 클래스 토글링 없음.

사이드바 감지#

제가 가장 좋아하는 패턴 중 하나 — 사이드바 존재 여부에 따라 레이아웃 변경:

css
.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}
 
/* If the layout contains a sidebar, switch to two columns */
.page-layout:has(.sidebar) {
  grid-template-columns: 1fr 300px;
}
 
/* Adjust main content width when sidebar is present */
.page-layout:has(.sidebar) .main-content {
  max-width: 65ch;
}

사이드바 컴포넌트를 DOM에 추가하면 레이아웃이 조정됩니다. 제거하면 다시 조정됩니다. CSS가 진실의 원천이지, 상태 변수가 아닙니다.

:has()와 다른 선택자 결합#

:has()는 다른 모든 것과 아름답게 조합됩니다:

css
/* Style an article only when it has a specific class of figure */
article:has(figure.full-bleed) {
  overflow: visible;
}
 
/* A navigation that changes when it contains a search input */
nav:has(input[type="search"]:focus) {
  background: oklch(98% 0 0);
  box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
 
/* Enable dark-mode at the component level */
.theme-switch:has(input:checked) ~ main {
  color-scheme: dark;
  background: oklch(15% 0 0);
  color: oklch(90% 0 0);
}

브라우저 지원은 이제 훌륭합니다. 모든 모던 브라우저가 2024년 초부터 :has()를 지원합니다. 사용하지 않을 이유가 없습니다.

CSS 네스팅: 네이티브, 마침내 안정화#

이건 과대 포장하지 않겠습니다. CSS 네스팅은 좋습니다. 컨테이너 쿼리나 :has()처럼 혁명적이지는 않습니다. 하지만 Sass를 사용해야 할 마지막 이유 중 하나를 없앴고, 그것이 중요합니다.

문법#

css
.card {
  background: white;
  border-radius: 0.5rem;
  padding: 1.5rem;
 
  .title {
    font-size: 1.25rem;
    font-weight: 600;
    margin-bottom: 0.5rem;
  }
 
  .description {
    color: oklch(40% 0 0);
    line-height: 1.7;
  }
 
  &:hover {
    box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
  }
 
  &.featured {
    border: 2px solid oklch(60% 0.2 260);
  }
}

이것은 유효한 CSS입니다. 빌드 단계 없음. 전처리기 없음. PostCSS 플러그인 없음. 브라우저가 네이티브로 처리합니다.

Sass와의 차이점#

알아둘 가치가 있는 몇 가지 문법 차이점이 있습니다:

css
/* CSS Nesting — works now in all browsers */
.parent {
  /* Direct class/element nesting works without & */
  .child {
    color: red;
  }
 
  /* & is required for pseudo-classes and compound selectors */
  &:hover {
    background: blue;
  }
 
  &.active {
    font-weight: bold;
  }
 
  /* Nested media queries — this is great */
  @media (width >= 768px) {
    flex-direction: row;
  }
}

초기 구현에서는 요소 선택자 앞에 &가 필요했습니다 (p 대신 & p). 그 제한은 완화되었습니다. 2025년 기준으로 모든 주요 브라우저가 기본 요소 네스팅을 지원합니다: .parent { p { ... } }가 잘 작동합니다.

중첩된 미디어 쿼리#

제 의견으로는 이것이 CSS 네스팅의 킬러 기능입니다. 선택자 네스팅이 아닌 — 규칙 블록 안에 미디어 쿼리를 넣을 수 있는 능력:

css
.hero {
  padding: 2rem;
  font-size: 1rem;
 
  @media (width >= 768px) {
    padding: 4rem;
    font-size: 1.25rem;
  }
 
  @media (width >= 1200px) {
    padding: 6rem;
    font-size: 1.5rem;
  }
}

.hero 스타일이 세 개의 다른 @media 블록에 흩어져 있고, 수백 줄 떨어져 있을 수 있었던 이전 방식과 비교해 보세요. 네스팅은 반응형 동작을 컴포넌트와 함께 배치합니다. 가독성이 극적으로 향상됩니다.

과도한 네스팅 금지#

경고 하나: 6단계 깊이로 중첩할 수 있다고 해서 그렇게 해야 한다는 의미는 아닙니다. Sass에서와 같은 조언이 여기에도 적용됩니다. 네스팅이 .page .section .card .content .text .highlight와 같은 선택자를 만들면, 명시도 괴물이자 유지보수 악몽을 만든 것입니다. 두세 단계가 적절합니다.

css
/* Good — two levels */
.nav {
  .link {
    color: inherit;
 
    &:hover {
      color: oklch(55% 0.2 260);
    }
  }
}
 
/* Bad — specificity nightmare */
.page {
  .layout {
    .sidebar {
      .nav {
        .list {
          .item {
            .link {
              color: red; /* good luck overriding this */
            }
          }
        }
      }
    }
  }
}

색상 함수: oklch()color-mix()가 모든 것을 바꾸다#

hsl()은 오랫동안 잘 사용되었습니다. rgb()보다 직관적이었습니다. 하지만 근본적인 결함이 있습니다: 지각적으로 균일하지 않습니다. hsl(60, 100%, 50%) (노란색)은 hsl(240, 100%, 50%) (파란색)과 같은 밝기 값을 가지지만 인간의 눈에는 극적으로 더 밝게 보입니다.

oklch()가 이기는 이유#

oklch()는 지각적으로 균일합니다. 동일한 밝기 값이 동일하게 밝게 보입니다. 이것은 색상 팔레트를 생성하고, 테마를 만들고, 접근 가능한 대비를 보장할 때 매우 중요합니다:

css
:root {
  /* oklch(lightness chroma hue) */
  --color-primary: oklch(55% 0.2 260);     /* blue */
  --color-secondary: oklch(55% 0.2 330);   /* purple */
  --color-success: oklch(55% 0.2 145);     /* green */
  --color-danger: oklch(55% 0.25 25);      /* red */
  --color-warning: oklch(75% 0.18 85);     /* yellow — higher L for equal perception */
 
  /* These all look equally "medium" to the human eye */
  /* With HSL, you'd need different lightness values for each hue */
}

세 값은 이해하면 직관적입니다:

  • 밝기 (0%~100%): 얼마나 밝거나 어두운지. 0%는 검정, 100%는 흰색.
  • 채도 (0~약 0.37): 얼마나 선명한지. 0은 회색, 높을수록 더 채도가 높습니다.
  • 색상 (0~360): 색상 각도. 0/360은 분홍빛 빨강, 145는 초록, 260은 파랑.

파생 색상을 위한 color-mix()#

color-mix()를 사용하면 런타임에 다른 색상으로부터 새 색상을 만들 수 있습니다. Sass darken() 함수도 필요 없고. JavaScript도 필요 없습니다. CSS만으로:

css
:root {
  --brand: oklch(55% 0.2 260);
 
  /* Lighten by mixing with white */
  --brand-light: color-mix(in oklch, var(--brand) 30%, white);
 
  /* Darken by mixing with black */
  --brand-dark: color-mix(in oklch, var(--brand) 70%, black);
 
  /* Create a subtle background */
  --brand-bg: color-mix(in oklch, var(--brand) 8%, white);
 
  /* Semi-transparent version */
  --brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
 
.button {
  background: var(--brand);
  color: white;
 
  &:hover {
    background: var(--brand-dark);
  }
 
  &:active {
    background: color-mix(in oklch, var(--brand) 60%, black);
  }
}
 
.button.secondary {
  background: var(--brand-bg);
  color: var(--brand-dark);
 
  &:hover {
    background: color-mix(in oklch, var(--brand) 15%, white);
  }
}

in oklch 부분이 중요합니다. srgb에서 혼합하면 탁한 중간 색상이 나옵니다. oklch에서 혼합하면 지각적으로 균일한 결과를 얻습니다. 항상 oklch에서 혼합하세요.

oklch()로 완전한 팔레트 구축#

단일 색상에서 전체 음영 팔레트를 생성하는 방법은 다음과 같습니다:

css
:root {
  --hue: 260;
  --chroma: 0.2;
 
  --color-50:  oklch(97% calc(var(--chroma) * 0.1) var(--hue));
  --color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
  --color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
  --color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
  --color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
  --color-500: oklch(55% var(--chroma) var(--hue));
  --color-600: oklch(48% var(--chroma) var(--hue));
  --color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
  --color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
  --color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
  --color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}

--hue145로 바꾸면 초록 팔레트가 됩니다. 25로 바꾸면 빨강이 됩니다. 밝기 단계가 지각적으로 균일합니다. 채도는 극단에서 줄어들어 가장 밝고 어두운 음영이 과포화되지 않습니다. 이런 종류의 것은 디자인 도구나 Sass 함수가 필요했습니다. 이제 CSS 8줄입니다.

스크롤 기반 애니메이션: JavaScript 불필요#

가장 많은 JavaScript를 삭제하게 만든 기능입니다. 스크롤 연동 애니메이션 — 진행 표시줄, 패럴랙스 효과, 등장 애니메이션, 전환이 있는 고정 헤더 — 는 IntersectionObserver, scroll 이벤트 리스너, 또는 GSAP 같은 라이브러리가 필요했습니다. 이제 CSS입니다.

스크롤 진행 표시기#

글 페이지 상단의 클래식 "읽기 진행 표시줄":

css
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: oklch(55% 0.2 260);
  transform-origin: left;
  z-index: 1000;
 
  animation: grow-progress linear both;
  animation-timeline: scroll();
}
 
@keyframes grow-progress {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

그게 다입니다. 전체 읽기 진행 표시기. JavaScript 없음. 스크롤 이벤트 리스너 없음. requestAnimationFrame 없음. "스크롤 퍼센트" 계산 없음. animation-timeline: scroll() 바인딩이 모든 것을 처리합니다.

스크롤 시 등장#

뷰포트에 진입할 때 페이드인되는 요소들:

css
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

animation-timeline: view()는 애니메이션을 뷰포트에서의 요소 가시성에 연결합니다. animation-range: entry 0% entry 100%는 요소가 뷰포트에 진입하기 시작하는 순간부터 완전히 보일 때까지 애니메이션이 실행된다는 의미입니다.

라이브러리 없는 패럴랙스#

css
.parallax-bg {
  animation: parallax linear both;
  animation-timeline: scroll();
}
 
@keyframes parallax {
  from {
    transform: translateY(-20%);
  }
  to {
    transform: translateY(20%);
  }
}
 
.parallax-section {
  overflow: hidden;
  position: relative;
}

배경 이미지가 스크롤과 다른 속도로 움직여 패럴랙스 효과를 만듭니다. 부드럽고, 성능이 좋으며 (브라우저가 GPU에서 합성 가능), JavaScript는 전혀 없습니다.

이름 있는 스크롤 타임라인#

더 많은 제어가 필요하면 스크롤 타임라인에 이름을 지정하고 다른 요소에서 참조할 수 있습니다:

css
.scroller {
  overflow-y: auto;
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
}
 
/* An element anywhere in the DOM can reference this timeline */
.indicator {
  animation: progress linear both;
  animation-timeline: --my-scroller;
}
 
@keyframes progress {
  from { width: 0%; }
  to { width: 100%; }
}

이것은 표시기와 스크롤러가 부모/자식 관계가 아닐 때도 작동합니다. 어떤 요소든 이름 있는 스크롤 타임라인에 연결할 수 있습니다. 이것은 스크롤 가능한 패널이 고정 헤더의 표시기를 구동하는 대시보드 UI에 강력합니다.

앵커 포지셔닝: 툴팁과 팝오버의 올바른 구현#

앵커 포지셔닝 이전에는 툴팁을 트리거 요소에 연결하려면 JavaScript가 필요했습니다. getBoundingClientRect()로 위치를 계산하고, 스크롤 오프셋을 처리하고, 뷰포트 충돌을 관리하고, 크기 변경 시 다시 계산해야 했습니다. Popper.js (현재 Floating UI) 같은 라이브러리가 이것이 매우 어려웠기 때문에 존재했습니다.

CSS 앵커 포지셔닝은 이것을 선언적으로 만듭니다:

css
.trigger {
  anchor-name: --my-trigger;
}
 
.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
 
  /* Position the tooltip's top-center at the trigger's bottom-center */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
 
  /* Fallback positioning if it overflows the viewport */
  position-try-fallbacks: flip-block, flip-inline;
}

자동 뷰포트 충돌 처리#

position-try-fallbacks 속성은 JavaScript로 200줄이 필요했을 부분입니다. 브라우저에게 "툴팁이 아래로 뷰포트를 벗어나면 위로 뒤집어라. 오른쪽으로 벗어나면 왼쪽으로 뒤집어라"라고 말합니다.

css
.dropdown-menu {
  position: fixed;
  position-anchor: --menu-button;
 
  /* Default: below the button, aligned to the left edge */
  top: anchor(bottom);
  left: anchor(left);
 
  /* If it doesn't fit below, try above. If it doesn't fit left-aligned, try right-aligned */
  position-try-fallbacks: flip-block, flip-inline;
 
  /* Add a gap between the anchor and the dropdown */
  margin-top: 4px;
}

이름 있는 위치 폴백#

폴백 위치에 대한 더 많은 제어가 필요하면 커스텀 시도 옵션을 정의할 수 있습니다:

css
@position-try --above-right {
  bottom: anchor(top);
  right: anchor(right);
  top: auto;
  left: auto;
  margin-top: 0;
  margin-bottom: 4px;
}
 
@position-try --left-center {
  right: anchor(left);
  top: anchor(center);
  left: auto;
  bottom: auto;
  translate: 0 -50%;
  margin-top: 0;
  margin-right: 4px;
}
 
.tooltip {
  position: fixed;
  position-anchor: --trigger;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 4px;
 
  position-try-fallbacks: --above-right, --left-center;
}

브라우저는 요소를 뷰포트 안에 유지하는 것을 찾을 때까지 각 폴백을 순서대로 시도합니다. 이것은 JavaScript에서 정말로 고통스러웠던 종류의 공간 추론입니다.

Popover API와의 앵커링#

앵커 포지셔닝은 새로운 Popover API와 완벽하게 조합됩니다:

html
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
 
<div popover id="my-popover" style="
  position-anchor: --btn;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 8px;
">
  Popover content here
</div>

보여주기/숨기기에 JavaScript 없음 (Popover API가 처리). 포지셔닝에 JavaScript 없음 (앵커 포지셔닝이 처리). 라이트 디스미스 동작에 JavaScript 없음 (Popover API가 그것도 처리). 전체 툴팁/팝오버/드롭다운 패턴 — 전체 npm 패키지를 구동했던 패턴 — 이 이제 HTML과 CSS입니다.

CSS Grid 서브그리드: 제대로 작동하는 중첩 정렬#

Grid는 강력하지만 하나의 답답한 제한이 있었습니다: 자식 그리드가 부모 그리드에 항목을 정렬할 수 없었습니다. 카드 행이 있고 각 카드의 제목, 콘텐츠, 푸터가 카드 간에 정렬되기를 원하면 운이 없었습니다. 각 카드의 내부 그리드는 독립적이었습니다.

서브그리드가 이것을 해결합니다.

카드 정렬 문제#

css
/* Parent grid */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}
 
/* Each card becomes a subgrid, aligning rows to the parent */
.card {
  display: grid;
  grid-row: span 3; /* Card spans 3 rows in the parent */
  grid-template-rows: subgrid;
  gap: 0; /* Card controls its own internal gap */
}
 
.card-title {
  font-weight: 600;
  padding: 1rem 1rem 0.5rem;
}
 
.card-body {
  padding: 0 1rem;
  color: oklch(40% 0 0);
}
 
.card-footer {
  padding: 0.5rem 1rem 1rem;
  margin-top: auto;
  border-top: 1px solid oklch(90% 0 0);
}

이제 세 카드의 제목이 같은 행에 정렬됩니다. 본문이 정렬됩니다. 푸터가 정렬됩니다. 한 카드에 두 줄 제목이 있고 다른 카드에 한 줄 제목이 있더라도 부모 그리드에 의해 정렬이 유지됩니다.

서브그리드 이전: 꼼수들#

서브그리드 없이 이 정렬을 달성하려면 다음이 필요했습니다:

  1. 고정 높이 (취약, 동적 콘텐츠에 깨짐)
  2. JavaScript 측정 (느림, 정렬 불일치 깜박임)
  3. 포기하고 정렬 불일치 수용 (흔함, 못생김)
css
/* The old hack — fragile and breaks with dynamic content */
.card-title {
  min-height: 3rem; /* pray that no title exceeds this */
}
 
.card-body {
  min-height: 8rem; /* even more prayer */
}

서브그리드는 이 꼼수를 불필요하게 만듭니다. 부모 그리드가 모든 카드에 걸쳐 각 행에서 가장 큰 콘텐츠를 기준으로 행 높이를 분배합니다.

폼 레이아웃을 위한 열 서브그리드#

서브그리드는 열에서도 작동하며, 폼 레이아웃에 완벽합니다:

css
.form {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.75rem 1rem;
}
 
.form-row {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
  align-items: center;
}
 
.form-row label {
  grid-column: 1;
  text-align: right;
  font-weight: 500;
}
 
.form-row input {
  grid-column: 2;
  padding: 0.5rem;
  border: 1px solid oklch(80% 0 0);
  border-radius: 0.375rem;
}

모든 레이블이 정렬됩니다. 모든 입력이 정렬됩니다. 레이블 열은 가장 넓은 레이블에 맞게 자동 조정됩니다. 하드코딩된 너비 없음.

View Transitions API: 프레임워크 없는 SPA 같은 네비게이션#

이것은 이 목록에서 가장 야심찬 기능입니다. View Transitions API를 사용하면 페이지 네비게이션 간에 애니메이션을 적용할 수 있습니다 — 교차 문서 네비게이션 (멀티 페이지 앱에서의 일반 링크 클릭) 포함. 정적 HTML 사이트에서 이제 부드럽고 애니메이션된 페이지 전환을 사용할 수 있습니다.

교차 문서 전환#

교차 문서 뷰 전환을 활성화하려면 이전 페이지와 새 페이지 모두에 단일 CSS 규칙을 추가합니다:

css
@view-transition {
  navigation: auto;
}

그게 다입니다. 네비게이션 시 브라우저가 페이지 간에 교차 페이드합니다. 기본 전환은 부드러운 투명도 페이드입니다. JavaScript 없음. 프레임워크 없음. 각 페이지에 CSS 두 줄만.

전환 커스터마이징#

특정 요소에 이름을 지정하여 어떤 전환이 발생하는지 커스터마이즈할 수 있습니다:

css
/* On both pages */
.page-header {
  view-transition-name: header;
}
 
.main-content {
  view-transition-name: content;
}
 
.hero-image {
  view-transition-name: hero;
}
 
/* Transition animations */
::view-transition-old(content) {
  animation: slide-out 0.3s ease-in both;
}
 
::view-transition-new(content) {
  animation: slide-in 0.3s ease-out both;
}
 
::view-transition-old(hero) {
  animation: fade-out 0.2s ease-in both;
}
 
::view-transition-new(hero) {
  animation: none; /* The new hero just appears */
}
 
@keyframes slide-out {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}
 
@keyframes slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

view-transition-name: hero를 가진 .hero-image가 있는 두 페이지 간에 네비게이션할 때, 브라우저가 자동으로 이전 페이지의 위치에서 새 페이지의 위치로 이미지를 애니메이션합니다. 모바일 개발에서의 "공유 요소 전환" 패턴이 이제 브라우저에 있습니다.

SPA 뷰 전환#

싱글 페이지 앱 (React, Vue, Svelte 등)의 경우, JavaScript API는 간단합니다:

css
/* CSS side — define your transition animations */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-in;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-out;
}
javascript
// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
  // Update the DOM here — React render, innerHTML swap, etc.
  updateContent(newPageData);
});

브라우저가 이전 상태를 스냅샷하고, 업데이트를 실행하고, 새 상태를 스냅샷한 후 둘 사이를 애니메이션합니다. 이름이 지정된 요소는 개별 전환을 받고, 나머지는 기본 크로스페이드를 받습니다.

사용자 선호 존중#

항상 prefers-reduced-motion을 존중하세요:

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

이제 사용을 중단할 수 있는 것들#

이러한 기능들은 총괄적으로 수년간 의존해온 많은 도구, 라이브러리, 패턴을 제거합니다. 제가 제거하거나 더 이상 사용하지 않는 것들입니다:

네스팅과 변수를 위한 Sass/SCSS#

CSS에는 네이티브 네스팅이 있습니다. CSS에는 커스텀 속성이 있습니다 (수년간 있어왔습니다). 사람들이 Sass를 찾았던 두 가지 주요 이유가 이제 언어에 있습니다. $variables와 네스팅만을 위해 Sass를 사용하고 있다면 중단할 수 있습니다.

css
/* Before: Sass */
$primary: #3b82f6;
 
.card {
  background: white;
  border: 1px solid lighten($primary, 40%);
 
  &:hover {
    border-color: $primary;
  }
 
  .title {
    color: darken($primary, 15%);
  }
}
 
/* After: Native CSS */
.card {
  --primary: oklch(55% 0.2 260);
 
  background: white;
  border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
 
  &:hover {
    border-color: var(--primary);
  }
 
  .title {
    color: color-mix(in oklch, var(--primary) 70%, black);
  }
}

CSS 버전이 더 강력합니다 — oklch는 지각적으로 균일한 색상 조작을 제공하고, color-mix는 런타임에 작동하며 (Sass lighten은 컴파일 타임 전용), 커스텀 속성은 JavaScript나 미디어 쿼리에서 동적으로 변경할 수 있습니다.

스크롤 애니메이션을 위한 JavaScript#

IntersectionObserver 스크롤 등장 코드를 삭제하세요. 스크롤 진행 표시줄 JavaScript를 삭제하세요. 패럴랙스 스크롤 핸들러를 삭제하세요. animation-timeline: scroll()animation-timeline: view()가 더 나은 성능으로 (메인 스레드가 아닌 합성 스레드) 이 모든 것을 처리합니다.

javascript
// Before: JavaScript you can delete
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
        observer.unobserve(entry.target);
      }
    });
  },
  { threshold: 0.1 }
);
 
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
css
/* After: CSS that replaces all of the above */
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes reveal {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

포지셔닝을 위한 Popper.js / Floating UI#

기본 툴팁/팝오버 포지셔닝만을 위해 Floating UI (이전 Popper.js)를 사용하고 있다면, CSS 앵커 포지셔닝이 이를 대체합니다. 일부 고급 기능 (가상 요소, 커스텀 미들웨어)은 잃지만, 90% 사용 사례 — "이 팝오버를 저 버튼 근처에 놓고 뷰포트 안에 유지해"에 대해 — CSS가 이제 네이티브로 합니다.

margin: auto 센터링 꼼수#

이건 새로운 것이 아니지만 여전히 margin: 0 auto를 어디서나 봅니다. 모던 레이아웃 도구가 대부분의 경우 불필요하게 만듭니다:

css
/* Old way */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
/* Better: CSS Grid or Flexbox */
.page {
  display: grid;
  place-items: center;
}
 
/* Or: container queries make the container itself responsive */
.content {
  container-type: inline-size;
  width: min(100% - 2rem, 1200px);
  margin-inline: auto; /* OK, still auto, but margin-inline is clearer */
}

margin: 0 auto는 여전히 잘 작동합니다. 하지만 수직 센터링이나 복잡한 정렬에 사용하고 있다면 대신 Flexbox나 Grid를 사용하세요.

컴포넌트 수준 반응형을 위한 미디어 쿼리#

이것이 가장 큰 변화입니다. 컴포넌트를 반응형으로 만들기 위해 @media 쿼리를 작성하고 있다면, 이제는 아마 잘못하고 있는 것입니다. 컨테이너 쿼리가 컴포넌트 수준 반응형의 기본이어야 합니다. @media 쿼리는 페이지 수준 결정에 남겨두세요: 레이아웃 변경, 네비게이션 패턴, 인쇄 스타일.

css
/* Before: Media query for component styling (wrong scope) */
@media (min-width: 768px) {
  .product-card {
    flex-direction: row;
  }
}
 
/* After: Container query (right scope) */
.product-card-wrapper {
  container-type: inline-size;
}
 
@container (min-width: 400px) {
  .product-card {
    flex-direction: row;
  }
}

큰 그림#

이러한 기능들은 고립되어 존재하지 않습니다. 조합됩니다. 컨테이너 쿼리 + :has() + 네스팅 + oklch() + 레이어 — 함께 사용하면 — 5년 전에는 상상할 수 없었던 CSS 작성 경험을 제공합니다:

css
@layer components {
  .card-wrapper {
    container-type: inline-size;
  }
 
  .card {
    --accent: oklch(55% 0.2 260);
 
    display: grid;
    gap: 1rem;
    padding: 1.5rem;
    border-radius: 0.5rem;
    background: white;
    border: 1px solid oklch(90% 0 0);
 
    &:has(img) {
      padding-top: 0;
 
      img {
        border-radius: 0.5rem 0.5rem 0 0;
        width: 100%;
        aspect-ratio: 16 / 9;
        object-fit: cover;
      }
    }
 
    &:has(.badge.new) {
      border-color: var(--accent);
    }
 
    .title {
      font-size: 1.125rem;
      font-weight: 600;
      color: oklch(20% 0 0);
      text-wrap: balance;
    }
 
    .meta {
      font-size: 0.875rem;
      color: oklch(50% 0 0);
    }
 
    @container (min-width: 500px) {
      grid-template-columns: 200px 1fr;
 
      &:has(img) {
        padding: 0;
 
        img {
          border-radius: 0.5rem 0 0 0.5rem;
          height: 100%;
          aspect-ratio: auto;
        }
      }
 
      .content {
        padding: 1.5rem;
      }
    }
  }
}

그 단일 블록이 처리하는 것:

  • 컴포넌트 수준 반응형 레이아웃 (컨테이너 쿼리)
  • 콘텐츠에 기반한 조건부 스타일링 (:has())
  • 깔끔한 선택자 구성 (네스팅)
  • 지각적으로 균일한 색상 (oklch)
  • 예측 가능한 명시도 관리 (@layer)

전처리기 없음. CSS-in-JS 런타임 없음. 이를 위한 유틸리티 클래스 없음. CSS가 처음부터 해야 했던 일을 하는 CSS일 뿐.

플랫폼이 따라잡았습니다. 마침내. 그리고 기다린 보람이 있었습니다.

관련 게시물