eunsoolib
DOM·CSS 브라우저 API

scope-style

CSS @scope + @layer로 컴포넌트 서브트리에 스타일을 한정

데모

donut scope demo

Outer styles p red. Inner card owns its own scope and styles its p blue. With donut on, the outer's red stops at the inner boundary; with it off, red leaks in and wins by source order.

Outer card <p> (unscoped child) — RED.

Inner card <p> — owns scope, should stay BLUE.

Inner stays blue ⇒ isolation holds. Toggle off and the inner p turns red ⇒ the boundary was doing the work.

빌드 단계도, 클래스명 조작도, 런타임 CSS 파서도 없습니다. 평범한 CSS를 쓰면 라이브러리가 그 CSS가 컴포넌트 자신의 DOM에만 닿도록 감싸 줍니다.

설치

pnpm add @cbcruk/scope-style react

브라우저의 네이티브 CSS @scope 지원이 필요합니다(아래 브라우저 지원 참고).

사용법

import { useScopedStyle } from '@cbcruk/scope-style'

function Card({ children }: { children?: React.ReactNode }) {
  const scope = useScopedStyle(
    `
    :scope  { display: grid; gap: 8px; padding: 16px; border: 1px solid #ddd; }
    .title  { font-weight: 700; }
    p       { color: crimson; }
    `,
    { layer: 'components' },
  )

  return (
    <article {...scope}>
      <h2 className="title">Title</h2>
      <p>이 문단은 빨간색입니다.</p>
      {children}
    </article>
  )
}

SSR / React 19: ScopedStyle + scopeFor

훅은 insertion effect에서 스타일을 주입하는데, 이 effect는 SSR 중에 실행되지 않습니다. 서버 렌더링에서는 실제 <style> 요소를 렌더하고 결정적인 scopeFor 헬퍼와 함께 씁니다.

import { ScopedStyle, scopeFor } from '@cbcruk/scope-style'

const css = `:scope { padding: 16px } p { color: crimson }`

function Card() {
  const { props } = scopeFor(css, { layer: 'components' })
  return (
    <article {...props}>
      <ScopedStyle css={css} layer="components" />
      <p>스코프가 적용되고, 서버에서 렌더됩니다.</p>
    </article>
  )
}

React 19에서 <ScopedStyle><style href precedence>를 내보내고, React가 이를 <head>로 끌어올리며 중복을 자동으로 제거합니다(스트리밍 SSR에서도 동작). React 18에서는 인라인으로 렌더되지만, 스코프가 위치가 아니라 셀렉터 기반이라 그대로 동작합니다.

API

useScopedStyle(css, options?) => { "data-scope": string }

클라이언트 훅. 공유·참조 카운트되는 <style> 하나를 <head>에 주입하고(마운트 시 추가, 마지막 인스턴스 언마운트 시 제거) 스코프 루트에 펼칠 props를 반환합니다. id는 CSS의 안정적인 해시라서 같은 컴포넌트의 모든 인스턴스가 <style> 노드 하나를 공유합니다.

scopeFor(css, options?) => { props, id, css }

아무것도 주입하지 않고 스코프를 계산하는 순수 함수. 같은 CSS·옵션이면 서버와 클라이언트에서 항상 같은 id가 나오므로 hydration에 안전합니다. props는 스코프 루트에 펼칠 { "data-scope": id }, css@scope(+ @layer)로 감싼 결과 CSS입니다.

<ScopedStyle css {...options} precedence? />

SSR에서도 렌더되는 <style> 요소. scopeFor와 같은 옵션을 받고, precedence 기본값은 'scoped'입니다.

options

옵션타입기본값설명
donutboolean | stringtruetrue → 중첩된 스코프에서 멈춤. string → 직접 지정한 경계 셀렉터. false → 경계 없음
layerstring규칙을 @layer <name>으로 감싸 덮어쓰기 순서를 예측 가능하게 함
scopeNamesbooleantrue@keyframes 이름과 animation/animation-name 참조에 스코프 id 접미사를 붙여 충돌 방지

설계 노트

셀렉터 앞에 id를 붙이면 되지 않나?

흔한 패턴(React의 <style> 문서 예시 등)은 useId() + #id .foo입니다. 이는 자손 결합자라서 #id .foo중첩된 자식 컴포넌트 안의 .foo에도 매칭되고, 스타일이 아래로 새어 나갑니다.

scope-style@scope (root) to ([data-scope])를 씁니다. to (...) 하한 경계가 도넛을 만들어, 스타일은 내 서브트리에 적용되되 자기 스코프를 가진 중첩 요소에서 멈춥니다. 이 라이브러리로 스타일링한 자식은 자기 모양을 그대로 유지합니다. 셀렉터 접두사로는 깔끔하게 표현할 수 없는 부분이 이 경계이고, React 내부(fiber)를 들여다보는 방식이 맞지 않는 이유이기도 합니다. 경계는 reconciler가 아니라 CSS cascade에 있습니다.

작성 시 참고

  • 셀렉터는 스코프 루트 기준 상대 경로입니다. 루트 요소는 :scope로 지정합니다.
  • 네이티브 중첩, &, @media, :hover, 커스텀 프로퍼티는 모두 그대로 동작합니다.
  • @scope 안에 선언한 이름 정의 at-rule은 스펙상 전역입니다. @keyframesscopeNames(기본 true)가 스코프별 이름으로 바꿔 주지만, @font-face / @property / @counter-style은 의도적으로 그대로 두고 개발 모드에서 경고만 출력합니다. 이 셋은 이름을 고유하게 지으세요.

브라우저 지원

네이티브 CSS @scope가 필요합니다. Baseline Newly Available입니다(Chrome/Safari는 2024년부터, Firefox는 2025년 12월 146부터). 더 오래된 환경을 지원해야 하면 @supports at-rule(@scope) { ... }로 분기하거나 빌드 타임 방식으로 대체하세요.

On this page