블로그
frontend
6遺??뚯슂

JS 없이도 UI가 반응한다: scroll-state 쿼리와 타입 있는 attr()

스크롤 가능 방향(위/아래)과 HTML 속성 값(색/숫자/키워드)을 CSS가 직접 읽고 검증하면, "작은 UI 디테일"을 JS 없이도 안정적으로 구현할 수 있습니다.

한 문장 결론: 스크롤 가능 방향(위/아래)과 HTML 속성 값(색/숫자/키워드)을 CSS가 직접 읽고 검증하면, “작은 UI 디테일”을 JS 없이도 안정적으로 구현할 수 있습니다. (MDN: Container scroll-state queries)

배경/문제

세로 스크롤 영역에서 “여기 더 내려갈 수 있어요” 같은 힌트를 주고 싶을 때가 많습니다. 대표적으로:

  • 코드 블록/표/리스트처럼 세로로 스크롤되는 영역이 있는 UI
  • 타이포그래피, 그리드 정렬처럼 레이아웃 디테일이 UX 품질을 좌우하는 화면

그런데 이 힌트(그림자)는 보통 JS로 스크롤 위치를 계산해서 토글합니다. 유지보수 포인트가 늘고, 스크롤 이벤트 성능도 신경 써야 하죠.

또 하나. HTML과 CSS 사이 값 전달도 자주 애매해집니다.

  • 테마 색을 바꾸려고 inline style을 쓰거나
  • 그리드 컬럼 수를 바꾸려고 class를 늘리거나
  • data-* 값을 쓰고 싶어도 CSS에서 “문자열”로만 처리되는 제약이 있었습니다 (MDN: attr())

포인트는 단순합니다. CSS가 “상태(스크롤 가능 방향)”와 “입력값(HTML 속성)”을 직접 다루면 JS 의존도를 크게 줄일 수 있습니다. (MDN: Container scroll-state queries)

핵심 개념

1) scroll-state 컨테이너 쿼리로 “스크롤 가능 방향”을 질의하기

container-type: scroll-state를 설정하면, CSS에서 @container scroll-state(scrollable: top|bottom|y...) 형태로 해당 방향으로 스크롤이 가능한지를 질의할 수 있습니다. (MDN: Container scroll-state queries)

2) 타입이 있는 attr()로 “HTML 속성 값을 CSS 타입으로 파싱하기”

attr()는 HTML 속성 값을 CSS에서 꺼내 쓰는 함수입니다. 여기에 type(<color>), type(<integer>) 같은 타입을 붙이면 파싱 + 검증 + fallback까지 한 번에 처리할 수 있습니다. (Chrome for Developers: advanced attr())

다이어그램 불러오는 중...

→ 기대 결과/무엇이 달라졌는지: HTML은 “값”만 제공하고, CSS는 그 값을 “타입 안정적으로” 해석합니다. 스크롤 힌트도 브라우저 상태를 CSS가 직접 질의해 토글할 수 있습니다. (MDN: Container scroll-state queries)

해결 접근

  1. 스크롤 영역에 컨테이너 이름/타입을 부여합니다. (container-type: scroll-state)
  2. 위/아래 그림자 레이어를 만들고 기본은 숨김 처리합니다.
  3. @container ... scrollable: top/bottom 조건이 참일 때만 그림자를 켭니다. (MDN: Container scroll-state queries)
  4. 테마/그리드 같은 값은 HTML 속성으로 전달하고, CSS에서 attr(... type(...), fallback)으로 받아 씁니다. (Chrome for Developers: advanced attr())

구현(코드)

아래 예시는 Next.js(App Router)에서 바로 붙여볼 수 있는 형태로 구성했습니다.

app/page.tsx

typescript
import styles from "./page.module.css";

export default function Page() {
  return (
    <main className={styles.page}>
      <section className={styles.theme} data-bg="white" data-fg="deeppink">
        <h1 className={styles.title}>Scroll Hint + Typed attr()</h1>
        <p className={styles.desc}>
          스크롤 영역은 위/아래 방향에 따라 그림자를 표시하고,
          테마/그리드 값은 HTML 속성으로 전달합니다.
        </p>
      </section>

      <section className={styles.grid} data-columns="3">
        {Array.from({ length: 9 }).map((_, i) => (
          <article key={i} className={styles.card}>
            Card {i + 1}
          </article>
        ))}
      </section>

      <section className={styles.scrollArea} aria-label="Scrollable content">
        <div className={styles.shadowTop} aria-hidden="true" />
        <div className={styles.shadowBottom} aria-hidden="true" />

        <div className={styles.scrollContent}>
          {Array.from({ length: 40 }).map((_, i) => (
            <p key={i} className={styles.line}>
              Line {i + 1} — 스크롤하면서 위/아래 그림자 변화를 확인합니다.
            </p>
          ))}
        </div>
      </section>
    </main>
  );
}

→ 기대 결과/무엇이 달라졌는지: 그림자/테마/그리드가 전부 “HTML 속성 + CSS”만으로 연결됩니다. JS로 스크롤 위치를 읽는 코드를 만들지 않아도 됩니다. (MDN: Container scroll-state queries)

app/page.module.css

css
.page {
  padding: 24px;
  display: grid;
  gap: 20px;
}

/* 1) Typed attr()로 테마 연결 */
.theme {
  padding: 16px;
  border-radius: 12px;

  /* 기본값(호환성/안전성) */
  background: black;
  color: white;
}

/* typed attr() 지원 환경에서만 업그레이드 */
@supports (color: attr(data-fg type(<color>), white)) {
  .theme {
    background: attr(data-bg type(<color>), black);
    color: attr(data-fg type(<color>), white);
  }
}

.title {
  margin: 0 0 6px;
}
.desc {
  margin: 0;
}

/* 2) Typed attr()로 그리드 컬럼 수 연결 */
.grid {
  display: grid;
  gap: 12px;

  /* 기본값 */
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

@supports (width: attr(data-columns type(<integer>), 3)) {
  .grid {
    --_cols: attr(data-columns type(<integer>), 3);
    grid-template-columns: repeat(var(--_cols), minmax(0, 1fr));
  }
}

.card {
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 12px;
  padding: 14px;
}

/* 3) scroll-state 쿼리로 스크롤 방향 힌트(그림자) */
.scrollArea {
  border-radius: 12px;
  border: 1px solid rgba(0, 0, 0, 0.12);

  height: 220px;
  overflow: auto;
  position: relative;

  /* 컨테이너 쿼리 설정 */
  container-name: scroller;
  container-type: scroll-state;
}

/* 그림자 레이어: 레이아웃을 밀지 않도록 음수 마진으로 겹치기 */
.shadowTop,
.shadowBottom {
  pointer-events: none;
  position: sticky;
  left: 0;
  right: 0;
  height: 14px;
  opacity: 0;
  transition: opacity 160ms ease;
  z-index: 1;
}

.shadowTop {
  top: 0;
  margin-bottom: -14px;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent);
}

.shadowBottom {
  bottom: 0;
  margin-top: -14px;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.22), transparent);
}

/* scroll-state 지원 환경에서만 방향별로 그림자 토글 */
@supports (container-type: scroll-state) {
  @container scroller scroll-state(scrollable: top) {
    .shadowTop {
      opacity: 1;
    }
  }

  @container scroller scroll-state(scrollable: bottom) {
    .shadowBottom {
      opacity: 1;
    }
  }
}

.scrollContent {
  padding: 12px 14px;
}

.line {
  margin: 0 0 10px;
}

→ 기대 결과/무엇이 달라졌는지: (1) 테마/그리드 값은 HTML 속성에서 CSS로 “타입 안정적으로” 이동하고, (2) 스크롤 가능 방향은 CSS가 직접 질의해서 위/아래 그림자를 자동으로 토글합니다. (MDN: Container scroll-state queries)

(추가) “enum처럼” 키워드만 받게 만들기

아래처럼 type(start | center | end)로 허용 키워드만 받게 만들면, 잘못된 값은 fallback으로 안전하게 떨어집니다. (MDN: type())

css
[scroll-snap] {
  scroll-snap-align: attr(scroll-snap type(start | center | end), start);
}

→ 기대 결과/무엇이 달라졌는지: HTML이 scroll-snap="nothing"처럼 엉뚱한 값을 보내도, CSS는 유효한 값만 받아들이고 기본값으로 안전하게 복구합니다. (MDN: type())

검증 방법(체크리스트)

스크롤 영역이 최상단일 때: 아래 그림자만 보인다. (MDN: Container scroll-state queries)
스크롤 영역이 중간일 때: 위/아래 그림자가 모두 보인다. (MDN: Container scroll-state queries)
스크롤 영역이 최하단일 때: 위 그림자만 보인다. (MDN: Container scroll-state queries)
data-columns에 잘못된 값(예: "abc")을 넣어도 레이아웃이 깨지지 않고 fallback으로 유지된다. (W3C: CSS Values and Units Level 5)
data-bg, data-fg에 유효하지 않은 색을 넣어도 기본 색(black/white)으로 동작한다. (Chrome for Developers: advanced attr())

흔한 실수/FAQ

Q1. attr(data-bg color, black)처럼 “타입 이름만” 쓰면 되나요?

공식 문서 기준으로는 type(<color>)처럼 type()로 감싸는 형태가 핵심입니다. (문법/지원은 환경에 따라 달라질 수 있습니다.) (Chrome for Developers: advanced attr())

Q2. attr()로 URL을 만들어 background-image에 넣을 수 있나요?

attr()로 만들어진 값은 URL로 사용될 때 제한이 걸릴 수 있습니다. 스타일 목적이라면 색/숫자/키워드 같은 “안전한 타입” 위주로 설계하는 편이 좋습니다. (W3C: CSS Values and Units Level 5)

Q3. 이 기능이 없는 브라우저에서는 어떻게 하나요?

  • 테마/그리드 값 전달: CSS 커스텀 프로퍼티(-var) + inline style이 가장 단순한 대안입니다. (MDN: Using CSS custom properties)
  • “enum” 매핑: [attr="value"] 속성 선택자로 분기하면 호환성이 넓습니다.
  • 스크롤 그림자: IntersectionObserver/scroll 이벤트data-scroll-top 같은 플래그를 토글하는 방식이 대안입니다. (MDN: IntersectionObserver)

요약(3~5줄)

  • scroll-state 컨테이너 쿼리는 스크롤 가능 방향을 CSS가 직접 질의해 그림자를 토글할 수 있게 합니다. (MDN: Container scroll-state queries)
  • 타입이 있는 attr()는 HTML 속성 값을 색/숫자/키워드로 파싱하고, 실패 시 fallback으로 안전하게 복구합니다. (Chrome for Developers: advanced attr())
  • @supports로 감싸면 점진적 향상(Progressive Enhancement) 패턴으로 적용하기 쉽습니다. (MDN: Conditional rules)

결론

타이포그래피와 그리드 정렬처럼 “디테일이 곧 품질”인 UI에서는, JS로 상태를 추적하기보다 CSS가 상태/입력값을 직접 다루게 만드는 편이 유지보수에 유리합니다.

scroll-state 쿼리와 타입 있는 attr()는 HTML–CSS 연결을 한 단계 더 강하게 만들어 줍니다. (MDN: Container scroll-state queries)

참고(공식 문서 링크)

관련 게시물