웹 개발에서 반응형 디자인은 필수죠. 지금까지는 주로 미디어 쿼리 (@media)를 사용해 브라우저 뷰포트(viewport)의 크기에 따라 레이아웃이나 스타일을 변경해왔습니다. 하지만 미디어 쿼리는 전체 화면 크기에만 반응하기 때문에, 같은 컴포넌트라도 어디에 배치되느냐에 따라 다르게 보이고 싶을 때 한계가 있었습니다.

예를 들어, 똑같은 "상품 카드" 컴포넌트가 넓은 메인 영역에 있을 때와 좁은 사이드바에 있을 때, 보여주고 싶은 정보의 양이나 레이아웃이 다를 수 있습니다. 미디어 쿼리만으로는 이런 세밀한 제어가 어려웠죠.

바로 이 문제를 해결하기 위해 등장한 것이 CSS 컨테이너 쿼리입니다!

CSS 컨테이너 쿼리란?

CSS 컨테이너 쿼리는 특정 요소(컨테이너)의 크기나 스타일에 따라 그 자식 요소들의 스타일을 변경할 수 있게 해주는 강력한 기능입니다. 뷰포트 전체가 아닌, 자신을 감싸고 있는 부모 또는 특정 상위 요소(컨테이너)의 크기를 기준으로 반응형 디자인을 적용할 수 있게 된 것입니다. 이로써 훨씬 더 모듈화되고 재사용 가능한 컴포넌트를 만들 수 있게 되었습니다.

주요 개념 및 사용법

  1. 컨테이너 정의 (container-type, container-name)
    • container-type: 컨테이너가 어떤 유형의 쿼리를 지원할지 지정합니다. (예: inline-size는 너비 기반)
    • container-name (선택 사항): 특정 컨테이너를 명시적으로 타겟팅하기 위해 이름을 지정할 수 있습니다.
  2. 먼저, 어떤 요소의 크기를 기준으로 삼을지 "쿼리 컨테이너"를 지정해야 합니다. 이는 container-type 속성을 사용해 정의합니다.
  3. 컨테이너에 반응하는 스타일 적용 (@container, 컨테이너 쿼리 단위)
    • @container 규칙: @media와 비슷하지만, 컨테이너의 크기를 조건으로 사용합니다.
    • 컨테이너 쿼리 길이 단위 (Container Query Length Units): 컨테이너 크기에 상대적인 단위입니다. (예: cqi는 컨테이너 인라인 크기의 1%)
  4. 컨테이너가 정의되면, 그 컨테이너의 크기에 따라 자식 요소들의 스타일을 변경합니다.

우리의 실제 사용 예시: 반응형 말풍선 폰트

이번 프로젝트에서 저희는 웹툰의 말풍선(SpeechBubbleText) 컴포넌트 내부 텍스트가 말풍선이 그려지는 컨테이너(WebtoonContainer)의 너비에 따라 유동적으로 변하길 원했습니다.

  1. 컨테이너 설정 (WebtoonContainer.tsx):
    말풍선의 기준이 될 WebtoonContainer 컴포넌트에 containerType: "inline-size" 스타일을 적용하여 너비 기반 쿼리 컨테이너로 만들었습니다.
  2. // WebtoonContainer.tsx export function WebtoonContainer({ children, className = "" }: WebtoonContainerProps) { return ( <div className={`relative max-w-md mx-auto flex flex-col items-center w-full ${className}`} style={{ containerType: "inline-size" }} // ✅ 컨테이너로 지정! > {children} </div> ); }
  3. Tailwind CSS 설정 (tailwind.config.js):
    • @tailwindcss/container-queries 플러그인을 추가하여 Tailwind 내에서 컨테이너 쿼리 단위를 사용할 수 있도록 했습니다.
    • fontSize 테마를 확장하여 bubble이라는 커스텀 폰트 크기를 정의했습니다. 이때 clamp() 함수cqi 단위를 사용하여, WebtoonContainer 너비가 특정 범위 내에서 변할 때 폰트 크기가 최소 0.875rem에서 최대 1.25rem 사이로 부드럽게 조절되도록 설정했습니다. (예: bubble: "clamp(0.875rem, 4.46cqi, 1.25rem)")
    • 이 설정으로 .text-bubble 이라는 유틸리티 클래스가 생성됩니다.
    // tailwind.config.js
    // ... (imports)
    export default {
      // ... (content)
      safelist: ['text-bubble'], // ✅ 동적 클래스명 사용을 위한 safelist 설정
      theme: {
        extend: {
          fontSize: {
            bubble: "clamp(0.875rem, 4.46cqi, 1.25rem)", // ✅ cqi 단위 사용
          },
        },
      },
      plugins: [require("@tailwindcss/container-queries")], // 또는 import 방식
    } // ...
  4. 컴포넌트 적용 (SpeechBubbleText.tsx):
    SpeechBubbleText 컴포넌트는 speechBubbleMap이라는 객체로부터 상황에 맞는 스타일 정보(여기서는 fontSizeClass: "text-bubble")를 받아와 동적으로 .text-bubble 클래스를 적용했습니다.처음에는 fontSizeClass 변수를 통해 동적으로 클래스를 적용했을 때 Tailwind가 .text-bubble을 인식하지 못하는 문제가 있었지만, tailwind.config.jssafelist'text-bubble'을 추가하여 이 문제를 해결했습니다.
  5. // SpeechBubbleText.tsx // ... const { text, /*...,*/ fontSizeClass } = speechBubbleMap[imageId](name); // fontSizeClass 값은 "text-bubble" // ... return ( <div className={`... ${fontSizeClass} ...`}> {/* .text-bubble 적용 */} {text} </div> );

이 과정을 통해, WebtoonContainer의 너비가 변함에 따라 그 안의 SpeechBubbleText의 글꼴 크기가 의도한 대로 부드럽게 조절되는 반응형 동작을 구현할 수 있었습니다!

컨테이너 쿼리의 장점

  • 컴포넌트 독립성 및 재사용성 향상
  • 더 세밀한 반응형 제어
  • 직관적인 스타일링

주요 사용 사례

  • 그리드 시스템 내의 카드 UI
  • 다양한 너비의 컬럼에 배치될 수 있는 위젯
  • 복잡한 UI에서 특정 패널 크기에 따른 요소 조정
  • 우리가 구현한 반응형 타이포그래피

브라우저 지원 현황 (Can I use...)

보내주신 "Can I use" 스크린샷(CSS Container Query Units 기준)을 보면, 글로벌 브라우저 지원율이 92.55% (2025년 5월 현재 기준 매우 높은 수치)로 나타납니다. 이는 컨테이너 쿼리 유닛(cqi, cqw 등)이 대부분의 최신 브라우저에서 잘 작동한다는 것을 의미합니다.

  • 주요 데스크톱 브라우저: Chrome (105+), Edge (105+), Safari (16.0+), Firefox (최신 버전, 예: 110+), Opera (91+) 등에서 안정적으로 지원됩니다.
  • 주요 모바일 브라우저: Safari on iOS (16.0+), Chrome for Android (최신 버전, 예: 105+), Samsung Internet (18.0+) 등 역시 지원 범위에 포함됩니다.

결론적으로, (2025년 5월 현재) CSS 컨테이너 쿼리 유닛은 IE와 같은 아주 오래된 브라우저를 제외하고는 대부분의 사용자가 경험할 수 있는, 실무에 충분히 적용 가능한 기능이라고 볼 수 있습니다. 물론, 극소수의 구형 브라우저 사용자를 고려해야 한다면 폴리필(polyfill)을 검토하거나 점진적 향상(progressive enhancement) 전략을 취할 수 있습니다. 하지만 대다수의 현대적인 웹 프로젝트에서는 자신 있게 사용해도 좋은 수준입니다.

마무리하며

CSS 컨테이너 쿼리는 웹 컴포넌트 디자인 방식에 큰 변화를 가져오는 중요한 기술입니다. 이제 대부분의 모던 브라우저에서 잘 지원하고 있으므로, 더욱 유연하고 지능적인 반응형 웹사이트 및 애플리케이션을 구축하는 데 적극적으로 활용해 보시길 바랍니다!


---

clamp 함수 : https://lim-2.tistory.com/139

clamp() 함수는 CSS에서 어떤 값이 특정 범위 내에 있도록 "고정(clamp)"하는 역할을 합니다. 즉, 최소값(MIN), 선호하는 값(VAL), 그리고 최대값(MAX) 세 가지 인자를 받아서, 선호하는 값이 최소값과 최대값 사이에 있을 때는 선호하는 값을 사용하고, 그렇지 않으면 최소값 또는 최대값으로 제한하는 매우 유용한 함수입니다.

clamp() 함수의 구문

property: clamp(MIN, VAL, MAX);
  • MIN (최소값): 이 값보다 작아질 수 없는 하한선입니다. VALMIN보다 작으면, clamp() 함수는 MIN 값을 반환합니다.
  • VAL (선호하는 값 또는 중앙값): 이 값이 MINMAX 사이에 있다면, clamp() 함수는 VAL 값을 반환합니다. 이 부분에 주로 뷰포트 너비(vw), 컨테이너 너비(cqi, cqw) 등의 상대 단위를 사용하여 유동적인 변화를 만듭니다.
  • MAX (최대값): 이 값보다 커질 수 없는 상한선입니다. VALMAX보다 크면, clamp() 함수는 MAX 값을 반환합니다.

clamp() 함수의 작동 방식

  1. VALMIN보다 작으면 → 결과는 MIN
  2. VALMAX보다 크면 → 결과는 MAX
  3. MINVALMAX 이면 → 결과는 VAL

 

clamp() 함수의 장점 및 사용 사례

  • 유동적이고 반응형인 크기 조절:
    VAL 인자에 vw, vh, cqi, cqw 같은 상대 단위를 사용하면, 뷰포트나 컨테이너 크기 변화에 따라 폰트 크기, 여백, 너비 등이 부드럽게 조절되는 유동적인 디자인을 쉽게 구현할 수 있습니다.
  • 명확한 경계 설정으로 제어력 향상:
    값이 한없이 작아지거나 커지는 것을 방지하여, 디자인이 깨지거나 가독성이 떨어지는 상황을 막을 수 있습니다. 최소/최대 크기를 명확히 제어할 수 있다는 것이 큰 장점입니다.
  • 복잡한 미디어 쿼리 대체 가능:
    단일 속성의 부드러운 크기 조절을 위해 여러 개의 미디어 쿼리 브레이크포인트를 사용해야 했던 경우를 clamp() 하나로 간결하게 대체할 수 있습니다.

주요 사용 사례:

  • 반응형 타이포그래피: (우리가 사용한 방식!) 화면이나 컨테이너 너비에 따라 글꼴 크기를 유동적으로 조절하면서도 너무 작거나 커지지 않도록 제어합니다.
  • 유동적인 여백(padding, margin) 설정
  • 반응형 레이아웃 요소의 너비 또는 높이 조절

우리가 사용한 예시 다시 보기

tailwind.config.js에서 fontSize.bubble에 적용했던 값을 살펴봅시다:

bubble: "clamp(0.875rem, 4.46cqi, 1.25rem)"

여기서 각 부분의 의미는 다음과 같습니다:

  • MIN = 0.875rem (약 14px):
    말풍선 텍스트의 글꼴 크기는 아무리 작아져도 14px 밑으로 내려가지 않습니다. 컨테이너가 매우 좁아져 4.46cqi 계산 값이 14px보다 작아지더라도 최소 14px은 보장됩니다.
  • VAL = 4.46cqi:
    이것이 우리가 원하는 "선호하는" 글꼴 크기입니다. cqi는 컨테이너의 인라인 너비(주로 가로 너비)의 1%를 의미하므로, 4.46cqi는 컨테이너 너비의 4.46% 크기를 갖게 됩니다. 즉, 컨테이너의 너비가 변하면 이 값도 함께 변하여 글꼴 크기가 유동적으로 조절됩니다.
  • MAX = 1.25rem (약 20px):
    말풍선 텍스트의 글꼴 크기는 아무리 커져도 20px를 초과하지 않습니다. 컨테이너가 매우 넓어져 4.46cqi 계산 값이 20px보다 커지더라도 최대 20px로 제한됩니다.

결과적으로 이 설정은, 글꼴 크기가 최소 14px, 최대 20px의 경계 안에서 컨테이너 너비에 따라 유동적으로 변하는 매우 우아한 반응형 타이포그래피를 구현하게 해줍니다.

clamp()와 단위

MIN, VAL, MAX 인자에는 px, rem, em, %, vw, vh, cqi 등 다양한 CSS 단위를 사용할 수 있습니다. 특히 VAL 부분에 상대 단위를 사용하여 그 유연함을 극대화하는 경우가 많습니다.

브라우저 지원

clamp() 함수는 (2025년 현재 기준) 주요 모던 브라우저(Chrome, Edge, Firefox, Safari 등)에서 매우 잘 지원하고 있으므로, 안심하고 사용하셔도 됩니다.

clamp() 함수는 CSS만으로도 매우 정교하고 유연한 반응형 디자인을 구현할 수 있게 해주는 강력한 도구입니다. 잘 활용하시면 코드도 간결해지고 유지보수도 용이해지는 경험을 하실 수 있을 거예요!

interface SkeletonProps {
  className?: string;
  spinnerSize?: number
}

export const Skeleton = ({ className, spinnerSize = 16 }: SkeletonProps) => {
  return (
  // 기존 코드
    <svg className={`mr-3 size-${spinnerSize} animate-spin `}
          viewBox="0 0 24 24"
        >
  );
};


interface SkeletonProps {
  className?: string;
  spinnerSize?: 4 | 8 | 12 | 16 | 20 | 24;
}

export const Skeleton = ({ className, spinnerSize = 16 }: SkeletonProps) => {
    const sizeClasses = {
        4: "size-4",
        8: "size-8",
        12: "size-12",
        16: "size-16",
        20: "size-20",
        24: "size-24",
      };
  return (
  // 개선 코드
    <svg className={`mr-3 size-${spinnerSize} animate-spin `}
          viewBox="0 0 24 24"
        >
  );
};

문제 배경

값을 집어 넣었는데 원하는 size의 원이 나오질 않았습니다

원인 파악

Tailwind는 빌드 시점에 실제 코드에서 사용된 클래스만 생성하기 때문에, 동적으로 생성된 클래스명은 인식하지 못합니다.

interface SkeletonProps {
  className?: string;
  spinnerSize?: 4 | 8 | 12 | 16 | 20 | 24; // 허용된 크기만 정의
}

export const Skeleton = ({ className, spinnerSize = 16 }: SkeletonProps) => {
  // 미리 정의된 매핑 객체 사용
  const sizeClasses = {
    4: 'size-4',
    8: 'size-8',
    12: 'size-12',
    16: 'size-16',
    20: 'size-20',
    24: 'size-24'
  };

  return (
    <div className={`w-full h-full flex items-center justify-center ${className}`}>
      <div className="flex items-center justify-center animate-pulse w-full h-full bg-gray-300">
        <svg
          className={`mr-3 ${sizeClasses[spinnerSize]} animate-spin`}
          viewBox="0 0 24 24"
        >
          {/* ... */}
        </svg>
      </div>
    </div>
  );
};

위와 같이 바꾸라는 답변을 받아 해결했습니다.

학습

Tailwind CSS의 빌드 프로세스 이해하기

Tailwind CSS의 작동 방식을 쉽게 설명해드리겠습니다.

1. Tailwind의 기본 동작

정적 클래스 사용 시

// 이렇게 직접 작성한 클래스는 빌드 시 인식됨
<div className="size-16">
  Hello
</div>

빌드 시 Tailwind는:

  1. 코드를 스캔하여 "size-16"이라는 클래스를 찾음
  2. 해당하는 CSS를 생성:
  3. .size-16 { width: 4rem; height: 4rem; }

동적 클래스 사용 시

const size = 16;
// 이렇게 동적으로 생성된 클래스는 빌드 시 인식되지 않음
<div className={`size-${size}`}>
  Hello
</div>

빌드 시 Tailwind는:

  1. size-${size}라는 문자열을 찾지만, 실제 어떤 클래스가 사용될지 알 수 없음
  2. 따라서 size-16 클래스에 대한 CSS를 생성하지 않음
  3. 결과적으로 런타임에 클래스가 존재하지 않게 됨

2. 실제 예시

// 작동하지 않는 예시
const spinnerSize = 16;
const className = `size-${spinnerSize}`; // "size-16"이라는 클래스가 빌드에 포함되지 않음

// 작동하는 예시
const className = "size-16"; // 이 클래스는 빌드 시점에 확실히 인식됨

3. 비유로 이해하기

이것은 마치 책의 색인과 비슷합니다:

  • 책을 출판할 때 (= 빌드 시점) 색인을 만듭니다
  • 색인에는 실제 책에 있는 단어들만 포함됩니다
  • 나중에 누군가 동적으로 새로운 단어를 추가하려고 해도 (= 런타임), 이미 색인은 만들어져 있어서 찾을 수 없습니다

Tailwind CSS의 클래스와 빌드 크기 관계

클래스를 많이 정의하는 것이 성능에 미치는 영향을 설명해드리겠습니다.

영향을 주는 부분

  1. 최종 CSS 파일 크기
    • 정의된 각 클래스는 최종 CSS 파일에 포함됨
    • 예를 들어:
      .size-4 { width: 1rem; height: 1rem; }
      .size-8 { width: 2rem; height: 2rem; }
      .size-12 { width: 3rem; height: 3rem; }
      /* ... */
    • 하지만 각 클래스는 매우 작은 크기 (대략 30-50바이트 정도)
  2. 실제 영향
    • size-4부터 size-24까지 (6개 클래스)
    • 총 추가되는 크기: 약 200-300바이트 정도
    • 현대 웹사이트 기준으로는 매우 미미한 크기
    • 일반적인 이미지 한 장이 이보다 훨씬 큼

결론

// 이 정도 크기는 성능에 실질적인 영향을 주지 않습니다
const sizeClasses = {
  4: "size-4",
  8: "size-8",
  12: "size-12",
  16: "size-16",
  20: "size-20",
  24: "size-24"
};

실제로 신경 쓸 부분은:

  • 수천 개의 불필요한 클래스를 정의하는 경우
  • 동적으로 생성되는 무한한 범위의 클래스들
  • 사용하지 않는 많은 유틸리티 클래스들

현재 사용 중인 6개 정도의 size 클래스는 전혀 문제되지 않습니다! 👍

웹 접근성은 모든 사용자가 웹 콘텐츠를 동등하게 이용할 수 있도록 보장하는 중요한 요소입니다. 오늘은 실제 구현된 이미지 갤러리 컴포넌트를 통해 접근성을 어떻게 개선했는지 살펴보겠습니다.

1. 시맨틱 HTML과 ARIA 속성 활용

적절한 시맨틱 요소 사용

<section
  className="relative rounded-lg overflow-hidden group"
  ref={containerRef}
  role="region" // 지워도 됨
  aria-label="이미지 갤러리"
  tabIndex={0}
>
  {/* 컴포넌트 내용 */}
</section>

여기서는 section 요소를 사용하고 role="region"aria-label을 추가하여 스크린 리더 사용자에게 이 영역이 무엇인지 명확히 알려줍니다.

role="region" : 직역으로 지역 역할 특정 영역의 랜드마크 역할을 합니다. 작성자가 중요하다고 생각하는 문서 영역을 식별하는 데 사용됩니다.

요소를 사용하면 접근 가능한 이름이 주어질 경우 섹션이 region 역할을 가지고 있음을 자동으로 전달합니다. 개발자는 항상 ARIA를 사용하는 것보다 이 경우

와 같은 올바른 의미론적 HTML 요소를 사용하는 것을 선호해야 합니다.
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/region_role
공식 홈페이지에 가보니 다음과 같은 얘기가 있었다

해당 내용을 perplexity에 물어보니

aria-label="이미지 갤러리"가 접근 가능한 이름을 제공하므로,

요소는 자동으로 region 역할을 가지게 됩니다.
라는 답변을 듣게되어 수정하였다.

ARIA 속성으로 상태 정보 제공

<button
  onClick={() => setIsExpanded(!isExpanded)}
  aria-label="썸네일 보기"
  aria-expanded={isExpanded}
  aria-controls="thumbnails-panel"
  tabIndex={0}
>
  <PiImages />
</button>

<aside
  id="thumbnails-panel"
  ref={thumbnailPanelRef}
  className={`... ${isExpanded ? "block" : "hidden"}`}
  role="dialog"
  aria-label="썸네일 갤러리"
  aria-modal={isExpanded}
  aria-hidden={!isExpanded}
>
  {/* 썸네일 패널 내용 */}
</aside>

여기서 주목할 점:

  • aria-expanded: 버튼의 현재 확장 상태를 알려줍니다.
  • aria-controls: 이 버튼이 제어하는 요소의 ID를 지정합니다.
  • aria-modal: 모달 다이얼로그임을 알려줍니다.
  • aria-hidden: 패널이 숨겨져 있을 때 스크린 리더가 무시하도록 합니다.
  • aria-label : 때때로 요소의 기본 접근자 이름이 없는 경우, 또는 그 콘텐츠를 명확하게 설명하지 못한 경우, 또는 aria-labelledby 속성을 통해 참조되는 dom 안에 보이는 컨텐츠가 없는 경우에 씁니다. 해당 요소에게 접근자 이름을제공하게 됩니다.

2. 키보드 접근성 개선

탭 포커스 관리

<button
  ref={prevButtonRef}
  onClick={handlePrev}
  aria-label="이전 이미지"
  className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/60 text-white px-3 py-5 rounded-2xl hover:bg-black/80 focus:bg-black/80 z-10 cursor-pointer control-visibility"
  tabIndex={0}
  type="button"
>
  <GrPrevious aria-hidden="true" />
</button>

모든 인터랙티브 요소에 tabIndex={0}를 적용하여 키보드 탐색이 가능하도록 했습니다.

아이콘에 대한 접근성

<GrPrevious aria-hidden="true" />

아이콘은 순수하게 장식 목적이므로 aria-hidden="true"를 사용하여 스크린 리더가 읽지 않도록 합니다.

감싸고 있는 버튼에는 내가 따로 text를 주지 않았기 때문에 aria-label 속성을 달아주도록 하자

포커스 트래핑 구현

포커스 트래핑

모달 다이얼로그가 열려 있을 때 포커스가 그 안에 갇히도록 하는 기능:

포커스 트래핑(Focus Trapping) 코드 분석

이미지 뷰어에 구현된 포커스 트래핑은 접근성을 높이기 위한 중요한 기능입니다. 특히 모달이나 대화상자처럼 분리된 UI 요소에서 키보드 사용자가 적절히 인터페이스를 탐색할 수 있도록 도와줍니다.

코드 분석

// 포커스 트래핑
useEffect(() => {
  if (!isExpanded) return;

  const handleTabKey = (e: KeyboardEvent) => {
    if (e.key !== "Tab" || !thumbnailPanelRef.current) return;

    const focusableElements = thumbnailPanelRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[
      focusableElements.length - 1
    ] as HTMLElement;

    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  };

  document.addEventListener("keydown", handleTabKey);
  return () => {
    document.removeEventListener("keydown", handleTabKey);
  };
}, [isExpanded]);

작동 방식

  1. 조건부 실행: 썸네일 패널이 확장된 상태(isExpandedtrue)일 때만 포커스 트래핑이 활성화됩니다.
  2. 포커스 가능한 요소 선택:이 코드는 모달 내에서 포커스 가능한 모든 요소를 선택합니다. 여기에는 버튼, 링크, 입력 필드, 선택상자, 텍스트 영역 및 탭 인덱스가 설정된 요소가 포함됩니다(단, tabindex="-1"인 요소는 제외).
  3. const focusableElements = thumbnailPanelRef.current.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' );
  4. 첫 번째와 마지막 요소 식별:선택된 요소들 중 첫 번째와 마지막 요소를 식별합니다.
  5. const firstElement = focusableElements[0] as HTMLElement; const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
  6. 순환 포커스 처리:
    • Shift+Tab 키 조합 처리: 첫 번째 요소에서 Shift+Tab을 누르면 마지막 요소로 포커스가 이동합니다.
    • if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } }
    • Tab 키 처리: 마지막 요소에서 Tab을 누르면 첫 번째 요소로 포커스가 이동합니다.
    • else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } }
  7. 이벤트 정리: 컴포넌트가 언마운트되거나 isExpandedfalse로 바뀌면 이벤트 리스너를 제거합니다.

이 기능의 중요성

  1. 접근성 향상: 키보드 사용자가 모달 내에서 탭을 눌러 순환적으로 포커스를 이동할 수 있습니다.
  2. 사용자 경험 개선: 사용자가 모달 외부로 실수로 포커스를 이동시키는 것을 방지합니다.
  3. WCAG 준수: 웹 콘텐츠 접근성 지침(WCAG)의 요구사항을 충족시킵니다.

이 코드는 모달(썸네일 패널)이 열려 있을 때 키보드 포커스가 해당 모달 내에서만 순환하도록 보장하여, 키보드 사용자에게 더 나은 네비게이션 경험을 제공합니다.

3. 키보드 단축키 제공

useEffect(() => {
  const handleKeyPress = (e: KeyboardEvent) => {
    if (e.key === "ArrowLeft") {
      handlePrev();
    } else if (e.key === "ArrowRight") {
      handleNext();
    } else if (e.key === "f") {
      toggleFullscreen();
    } else if (e.key === "Escape" && isExpanded) {
      setIsExpanded(false);
    }
  };

  window.addEventListener("keydown", handleKeyPress);
  return () => {
    window.removeEventListener("keydown", handleKeyPress);
  };
}, [handlePrev, handleNext, toggleFullscreen, isExpanded]);

왼쪽/오른쪽 화살표 키, 'f' 키, ESC 키 등의 단축키를 제공하여 키보드 사용자의 편의성을 높였습니다.

4. 포커스 관리와 메모리

useEffect(() => {
  if (isExpanded) {
    lastFocusedElementRef.current = document.activeElement as HTMLElement;

    setTimeout(() => {
      if (closeButtonRef.current) {
        closeButtonRef.current.focus();
      }
    }, 100);
  } else {
    setTimeout(() => {
      if (lastFocusedElementRef.current) {
        lastFocusedElementRef.current.focus();
      }
    }, 100);
  }
}, [isExpanded]);

이 코드는:

  1. 모달이 열릴 때 현재 포커스된 요소를 기억합니다.
  2. 모달의 닫기 버튼에 포커스를 이동시킵니다.
  3. 모달이 닫힐 때 이전에 포커스되어 있던 요소로 포커스를 복원합니다.

5. 시각적 피드백과 상태 표시

현재 상태 알림

<div
  className="absolute bottom-4 left-4 bg-black/50 text-white px-3 py-1 rounded-full text-sm cursor-default control-visibility"
  aria-live="polite"
  role="status"
>
  {currentIndex + 1} / {totalImagesNumber}
</div>

aria-live="polite"를 사용하여 내용이 변경될 때 스크린 리더가 이를 알려주도록 합니다.

썸네일에 현재 선택 상태 표시

<div
  role="button"
  tabIndex={0}
  aria-label={`이미지 ${index + 1}${
    currentIndex === index ? " (현재 선택됨)" : ""
  }`}
>
  {/* 썸네일 내용 */}
</div>

현재 선택된 이미지에는 "(현재 선택됨)"이라는 텍스트를 aria-label에 추가하여 스크린 리더 사용자에게 현재 위치를 알려줍니다.

6. 컴포넌트 렌더링 순서의 중요성

return (
  <section>
    {/* 이전/다음 버튼 - 먼저 렌더링 */}
    <button ref={prevButtonRef}>...</button>
    <button ref={nextButtonRef}>...</button>

    {/* 컨트롤 버튼 */}
    <button onClick={() => setIsExpanded(!isExpanded)}>...</button>
    <button onClick={toggleFullscreen}>...</button>

    {/* 이미지 컨테이너 - 나중에 렌더링 */}
    <div>
      <TransformViwer ... />
    </div>

    {/* 기타 컴포넌트 */}
  </section>
);

여기서 중요한 점은 버튼들을 TransformViwer 컴포넌트보다 먼저 렌더링하는 것입니다. 이렇게 하면 DOM 순서상 버튼이 이미지 위에 위치하게 되어 키보드 탐색이 제대로 작동합니다.

7. CSS를 통한 접근성 개선

.control-visibility {
  @apply transition-opacity duration-150 opacity-0 group-hover:opacity-100 group-focus:opacity-100 group-focus-within:opacity-100 focus:opacity-100;
}

이 클래스는:

  • 컨테이너에 마우스를 올릴 때 컨트롤이 표시됩니다 (group-hover:opacity-100)
  • 컨테이너에 포커스가 있을 때 컨트롤이 표시됩니다 (group-focus:opacity-100)
  • 컨테이너 내부 요소에 포커스가 있을 때도 컨트롤이 표시됩니다 (group-focus-within:opacity-100)
  • 컨트롤 자체에 포커스가 있을 때도 표시됩니다 (focus:opacity-100)

결론

이 이미지 갤러리 컴포넌트는 다양한 접근성 기법을 구현하여 모든 사용자에게 동등한 경험을 제공합니다:

  1. 시맨틱 HTML과 ARIA 속성을 통한 구조적 접근성
  2. 키보드 탐색 지원과 포커스 관리
  3. 단축키를 통한 편리한 조작
  4. 상태 변화에 대한 적절한 알림
  5. 올바른 컴포넌트 렌더링 순서 고려
  6. CSS를 통한 시각적 접근성 강화

이러한 접근성 개선 사항들은 단순히 장애를 가진 사용자뿐만 아니라 키보드 사용자, 모바일 사용자 등 모든 사용자에게 더 나은 경험을 제공합니다.

완성된 동작

// 문제의 코드: css 속성이 잘못된 경우
return (
  <section className="relative rounded-lg overflow-hidden group">
    {/* 이미지 컨테이너 */}
    <div className="aspect-[4/3] max-h-[80vh]">
      <TransformViwer 
        currentImageSrcMetadata={currentImageSrcMetadata}
        isLoaded={mainImageIsLoaded}
      />
    </div>

    <aside
        id="thumbnails-panel"
        ref={thumbnailPanelRef}
        className={`absolute left-0 top-0 bg-black bg-opacity-80 w-full md:w-1/2 h-full overflow-y-auto z-30 ${
          isExpanded ? "opacity-100" : "opacity-0"
        }`}
        role="dialog"
        aria-label="썸네일 갤러리"
        aria-modal={isExpanded}
        aria-hidden={!isExpanded}
      >
        ... 내부 코드
 </aside>

    {/* 이전/다음 버튼 - humbnails 뒤에 렌더링됨 */}
    <button 
      ref={prevButtonRef}
      onClick={handlePrev}
      aria-label="이전 이미지"
      className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/60 text-white px-3 py-5 rounded-2xl z-10 control-visibility"
      tabIndex={0}
    >
      <GrPrevious aria-hidden="true" />
    </button>
    <button 
      ref={nextButtonRef}
      onClick={handleNext}
      aria-label="다음 이미지"
      className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/60 text-white px-3 py-5 rounded-2xl z-10 control-visibility"
      tabIndex={0}
    >
      <GrNext aria-hidden="true" />
    </button>
  </section>
);

문제 상황

이미지 갤러리에서 '이전', '다음' 버튼을 구현했는데, tabIndex={0}을 명시적으로 설정했음에도 불구하고 키보드 탭 키로 해당 버튼에 포커스가 되지 않았습니다. 스타일과 속성은 모두 올바르게 적용되었으나, 어떤 이유에서인지 키보드 네비게이션이 작동하지 않았습니다.

원인 파악

많은 디버깅 끝에 발견한 원인은 의외로 단순했습니다.

1. 숨겨져 있는 썸네일 컴포넌트의 css 속성이

isExpanded ? "opacity-100" : "opacity-0"

와 같이 opacity로 되어있어 ( opacity 속성은 숨겨져 있음에도 tab focus가 가능하다)

관련 내용: https://lim-2.tistory.com/110

[엘리먼트를 안 보이게 하는 다양한 방식과 각각의 장단점

엘리먼트를 안 보이게 하는 다양한 방식과 각각의 장단점CSS에서 요소를 시각적으로 숨기는 여러 방법이 있으며, 각 방법마다 고유한 장단점이 있습니다. 특히 접근성 측면에서 큰 차이가 있습

lim-2.tistory.com](https://lim-2.tistory.com/110)

숨겨져 있는 컴포넌트 내부로 이동한거였습니다

해결책

해결책은 간단했습니다. 엘리먼트의 숨김을 처리하는 css 속성을

isExpanded ? "block" : "hidden"

hidden 
===
{
    display: none
}

으로 바꿔주니 원하는 대로 내부에 foucs 되지않고 외부에 있는 요소들 에게만 이동했습니다.

추가적인 깨달음

설계할 때는 사용자의 자연스러운 행동 패턴을 이해하는 것이 중요합니다. 제가 예측한 사용자 행동 우선순위는 다음과 같습니다.

예상되는 사용자 행동 패턴

  1. 기본 탐색: 좌우 버튼 사용
    • 가장 빈번하게 사용되는 행동입니다.
    • 사용자는 자연스럽게 화살표 버튼이나 키보드 방향키를 통해 이미지 간 이동을 시도합니다.
    • 이는 선형적인 콘텐츠 소비 방식과 일치하여 직관적입니다.
    • 일반적으로 원하는 사진을 찾을 때 좌우 버튼을 가장 우선해서 사용한다고 생각했습니다.
  2. 효율적인 탐색: 썸네일 갤러리 활용
    • 여러 이미지를 빠르게 살펴보고 특정 이미지로 직접 이동하려는 사용자들이 많습니다.
    • 특히 많은 수의 이미지가 있을 때 중간 지점으로 빠르게 이동하는 데 유용합니다.
    • 원하는 자동차의 부위를 빠르게 탐색할 때 사용합니다.
  3. 몰입형 보기: 전체화면 모드
    • 자동차의 이미지를 큰 화면으로 보고 싶을 때 사용합니다
  4. 세부 정보 확인: 확대/축소 기능
    • 원하는 부분을 고르고 자동차의 세부 사항을 보기 위해 확대, 축소 버튼들을 사용합니다.

제가 생각하는 사용자의 행동 패턴이었습니다

탐색을 우선으로 두고 그 뒤에 세부적인 조작이 올 것 이라고 생각했습니다.

 

그래서 tab을 사용한 focus의 순서는 컴포넌트 렌더링의 순서이기 때문에

해당하는 동작들을 담당하는 버튼들을 아래와 같이 배치하여 원하는 동작을 이뤄냈습니다.

// 수정한 코드
return (
    <button 
      ref={prevButtonRef}
      onClick={handlePrev}
      aria-label="이전 이미지"
      className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/60 text-white px-3 py-5 rounded-2xl z-10 control-visibility"
      tabIndex={0}
    >
      <GrPrevious aria-hidden="true" />
    </button>
    <button 
      ref={nextButtonRef}
      onClick={handleNext}
      aria-label="다음 이미지"
      className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/60 text-white px-3 py-5 rounded-2xl z-10 control-visibility"
      tabIndex={0}
    >
      <GrNext aria-hidden="true" />
    </button>
    {/* 썸네일 컨테이너 */}
     <aside
        id="thumbnails-panel"
        ref={thumbnailPanelRef}
        className={`absolute left-0 top-0 bg-black bg-opacity-80 w-full md:w-1/2 h-full overflow-y-auto z-30 ${
          isExpanded ? "block" : "hidden"
        }`}
        role="dialog"
        aria-label="썸네일 갤러리"
        aria-modal={isExpanded}
        aria-hidden={!isExpanded}
      >
        ... 내부 코드
     </aside>

     <section className="relative rounded-lg overflow-hidden group">
    {/* 이미지 컨테이너 */}
    <div className="aspect-[4/3] max-h-[80vh]">
      <TransformViwer 
        currentImageSrcMetadata={currentImageSrcMetadata}
        isLoaded={mainImageIsLoaded}
      />
    </div>
  </section>
);

 

결론

  • 적절한 css 속성 활용으로 의도에 맞는 동작을 하게 함
  • 사용자의 동작을 예상하여 컴포넌트를 배치하여 컴포넌트 렌더링 순서를 조정함

엘리먼트를 안 보이게 하는 다양한 방식과 각각의 장단점

CSS에서 요소를 시각적으로 숨기는 여러 방법이 있으며, 각 방법마다 고유한 장단점이 있습니다. 특히 접근성 측면에서 큰 차이가 있습니다. 주요 방법들을 살펴보겠습니다:

1. display: none

.hidden {
  display: none;
}

장점:

  • 요소가 DOM에서 완전히 사라진 것처럼 동작 (레이아웃에서 공간 차지하지 않음)
  • 모든 브라우저에서 일관되게 작동
  • 자식 요소도 모두 함께 숨겨짐

단점:

  • 접근성 트리에서 요소가 제거됨 (스크린 리더가 읽지 못함)
  • JavaScript로 포커스를 받을 수 없음
  • 애니메이션/전환 효과 적용 불가
  • 숨겨진 콘텐츠가 SEO에 영향을 줄 수 있음

2. visibility: hidden

.invisible {
  visibility: hidden;
}

장점:

  • 레이아웃에서 공간은 그대로 유지됨
  • 자식 요소도 기본적으로 모두 숨겨짐
  • visibility: visible을 통해 자식 요소만 개별적으로 표시 가능

단점:

  • 접근성 트리에서 요소가 제외됨 (스크린 리더가 읽지 못함)
  • 포커스를 받을 수 없음
  • 요소가 공간을 계속 차지함

3. opacity: 0

.transparent {
  opacity: 0;
}

장점:

  • 레이아웃에서 공간 유지됨
  • 요소는 여전히 클릭 가능하고 포커스를 받을 수 있음
  • 애니메이션/전환 효과 적용 가능
  • 접근성 트리에 남아있음

단점:

  • 완전히 눈에 보이지 않더라도 여전히 상호작용 가능 (의도하지 않은 클릭 발생 가능)
  • 공간을 계속 차지함
  • pointer-events: none을 추가로 적용하면 클릭은 방지할 수 있지만, 포커스는 여전히 가능

4. opacity: 0 + pointer-events: none

.transparent-no-interaction {
  opacity: 0;
  pointer-events: none;
}

장점:

  • 레이아웃에서 공간 유지됨
  • 마우스 이벤트(클릭 등)는 차단됨
  • 애니메이션/전환 효과 적용 가능

단점:

  • 키보드 포커스가 여전히 가능할 수 있음 (브라우저에 따라 다름)
  • 공간을 계속 차지함
  • 접근성 측면에서 혼란스러울 수 있음

5. CSS 위치 활용 (position: absolute + 화면 밖으로 이동)

.offscreen {
  position: absolute;
  left: -9999px;
  top: -9999px;
}

장점:

  • 레이아웃에 영향을 주지 않음
  • 접근성 트리에 남아있음 (스크린 리더가 읽을 수 있음)
  • 포커스를 받을 수 있음

단점:

  • 대형 웹사이트에서 많은 요소에 사용하면 렌더링 성능에 영향을 줄 수 있음
  • 좌표 값이 매우 큰 경우 스크롤 이슈가 발생할 수 있음

6. 스크린 리더용 숨김(권장 접근성 패턴)

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

장점:

  • 시각적으로는 보이지 않지만 스크린 리더는 접근 가능
  • 레이아웃에 영향을 주지 않음
  • 접근성 유지하면서 시각적으로 숨김

단점:

  • 시각적으로 완전히 숨기는 용도로만 사용해야 함 (일반 사용자에게 보여질 내용에는 부적합)
  • 다수의 CSS 속성 필요

7. HTML hidden 속성

<div hidden>내용</div>

장점:

  • 간단하고 명확한 문법
  • display: none과 동일한 효과
  • 시맨틱하게 "이 요소는 현재 관련이 없다"는 의미 전달

단점:

  • display: none과 동일한 접근성 문제
  • JavaScript로 조작해야 할 때 추가 작업 필요

이 방법들을 접근성 측면에서 비교

  1. 탭 포커스 가능여부:
    • opacity: 0
    • ✅ CSS 위치 활용
    • ✅ 스크린 리더용 숨김
    • display: none
    • visibility: hidden
    • ⚠️ opacity: 0 + pointer-events: none (브라우저별 차이)
  2. 스크린 리더 접근 가능여부:
    • opacity: 0
    • ✅ CSS 위치 활용
    • ✅ 스크린 리더용 숨김
    • display: none
    • visibility: hidden
    • opacity: 0 + pointer-events: none
  3. 공간 차지여부:
    • display: none (차지하지 않음)
    • visibility: hidden (여전히 차지)
    • opacity: 0 (여전히 차지)
    • opacity: 0 + pointer-events: none (여전히 차지)
    • ✅ CSS 위치 활용 (레이아웃에서 제외)
    • ✅ 스크린 리더용 숨김 (레이아웃에서 제외)

상황별 권장 사용 방법

  1. 일시적으로 요소를 숨겼다가 보여주는 UI 컴포넌트 (탭, 아코디언, 드롭다운 등):
    • opacity: 0 + 선택적으로 transform 조합 (애니메이션 가능)
  2. 접근성이 중요한 요소(스크린 리더가 읽어야 하는데 시각적으로는 숨기고 싶은 경우):
    • .sr-only 클래스 사용
  3. 완전히 숨기고 상호작용도 방지하고 싶은 경우:
    • display: none 또는 HTML hidden 속성
  4. 자리는 유지하면서 시각적으로만 숨기려는 경우:
    • visibility: hidden 또는 opacity: 0 + pointer-events: none
  5. 이미지 갤러리 등에서 이전/다음 버튼 처리:
    • 호버 시에만 나타나게: opacity: 0 + :hoveropacity: 1
    • 키보드 사용자도 고려: 포커스 시에도 표시 (opacity: 0 + :focus-withinopacity: 1)

지금의 이미지 뷰어 컴포넌트에서는 버튼을 숨기려면 opacity: 0를 사용하되, 포커스는 여전히 받을 수 있도록 하고, :focus 또는 :focus-visible 상태에서 opacity: 1로 변경하는 것이 가장 좋은 접근법입니다.

+ Recent posts