shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

2026.09.15·약 10분·작성: 해비·블로그 소개
shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성 학습 표지

이 글은 프론트엔드 라이브러리 연결 과정의 일부입니다. React 컴포넌트·props·state·이벤트와 TypeScript 객체·배열 타입을 먼저 익혀 주세요. 원본 강의의 문장을 옮기는 대신 독립적인 예제와 확인 과제를 구성했습니다.

학습 목표: 여러 하위 컴포넌트를 조합하고 트리거·내용·키보드 확인 항목을 구분합니다.

예상 학습 시간: 45분. 개인별 차이가 있으며 설치 시간은 제외합니다.

함께 동작하는 부품을 조합합니다

Dialog나 Carousel은 태그 하나에 모든 설정을 몰아넣기보다 역할별 부품을 조합합니다. Root는 상태와 문맥을 공유하고 Trigger는 열기 동작, Content는 보여줄 내용을 맡습니다. 컴포넌트 이름만 외우기보다 무엇이 버튼이고 무엇이 실제 내용인지 구분하는 것이 먼저입니다.

Dialog와 Popover 선택

사용자가 현재 흐름에서 잠시 집중해야 하는 안내나 입력은 Dialog를 검토합니다. 특정 버튼 주변의 짧은 보충 설명에는 Popover를 사용할 수 있습니다. 모든 도움말을 모달로 만들면 이동 부담이 늘어납니다. 반대로 중요한 확인을 순간적인 팝오버에만 두면 놓칠 수 있습니다. 삭제 확인에는 작업 결과와 취소 경로까지 설계해야 합니다.

이 예제는 Radix 계열 asChild API를 사용합니다. Trigger가 별도 button을 만들지 않고 자식 Button을 사용하게 하여 button 안에 button이 들어가는 구조를 피합니다. 다른 primitive 계열로 생성한 shadcn 컴포넌트는 API가 다를 수 있으므로 이름이 같다고 혼용하지 마세요. @/components/ui에서 가져오면 프로젝트에 정의된 스타일과 구조를 사용합니다. Radix 직접 사용 자체가 금지되는 것은 아니지만 그 경우 스타일과 조합 책임을 직접 맡습니다.

제목과 설명·초점

DialogTitle은 대화상자의 이름을, DialogDescription은 목적을 설명합니다. 단순한 화면 제목 장식이 아닙니다. 열었을 때 초점이 안으로 이동하고 Tab이 의도대로 순환하는지, Escape로 닫은 뒤 열기 버튼으로 돌아오는지 확인합니다. 배경 클릭으로 닫히는 것은 기본 동작에 따른 선택이며, 입력 중 데이터가 사라져도 되는지 별도로 판단합니다.

Carousel의 폭과 버튼

CarouselContent 안에서 CarouselItem이 반복됩니다. basis-full은 한 장, md:basis-1/2는 해당 화면 폭 이상에서 두 장을 배치하려는 설정입니다. gap·패딩·컨테이너 폭도 실제 보이는 수에 영향을 줍니다. Previous·Next가 바깥쪽에 배치되면 가장자리에서 잘리지 않을 여백을 확보합니다. 이 실습은 자동 재생 없이 버튼으로 이동하므로 내용을 읽는 속도를 사용자가 결정합니다.

동작 확인 결과
Dialog 열기·Escape 닫힌 뒤 원래 버튼으로 초점 복귀
Popover 열기 트리거 주변에 설명 표시
좁은 화면 / 넓은 화면 한 장 / 두 장의 카드 표시
Carousel 처음·마지막 갈 수 없는 방향 버튼 비활성화

직접 해보기와 확인 질문

Dialog 설명을 본인의 학습 목표로 바꾸고 키보드로만 전체 화면을 사용해 보세요. CarouselItem을 하나 더 추가한 뒤 마지막 버튼 상태를 확인하세요.

풀이 기준 보기

마우스 없이 열기·읽기·닫기가 가능해야 합니다. 카드 개수가 바뀌어도 끝에서 더 진행되지 않아야 합니다. jsdom 테스트만으로 실제 초점·스크롤·보조기기 품질을 확정할 수 없습니다.

공통 실습 실행

아래 공통 ZIP을 풀고 Node.js 24 환경에서 실행합니다. package-lock.json에 실습 버전을 고정했습니다. 본문의 경로와 ZIP의 경로는 같습니다.

npm ci
npm run dev

터미널에 표시된 주소를 열고 이 글에 해당하는 메뉴를 선택하세요. 서버 Todo만 별도 터미널의 npm run server가 필요합니다. 본문 코드에서 생략한 공통 설정과 UI 파일까지 ZIP에 포함되어 있습니다.

프론트엔드 라이브러리 공통 실습 ZIP

src/pages/overlay-page.tsx

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from "@/components/ui/dialog";
import {
  Popover,
  PopoverTrigger,
  PopoverContent,
} from "@/components/ui/popover";
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel";

export default function OverlayPage() {
  return (
    <section>
      <h2>Dialog·Popover·Carousel</h2>
      <div className="flex flex-wrap gap-4">
        <Dialog>
          <DialogTrigger asChild>
            <Button type="button">학습 안내 열기</Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>실습 확인</DialogTitle>
              <DialogDescription>
                Escape로 닫고 열기 버튼으로 초점이 돌아오는지 확인하세요.
              </DialogDescription>
            </DialogHeader>
            <p>입력 전에 목표와 저장 범위를 확인합니다.</p>
          </DialogContent>
        </Dialog>
        <Popover>
          <PopoverTrigger asChild>
            <Button type="button" variant="outline">
              도움말 열기
            </Button>
          </PopoverTrigger>
          <PopoverContent>
            <p>Popover는 주변 정보를 짧게 제공합니다.</p>
          </PopoverContent>
        </Popover>
      </div>
      <Carousel className="mx-12 my-8" opts={{ align: "start" }}>
        <CarouselContent>
          {["스타일", "라우팅", "상태", "서버"].map((text) => (
            <CarouselItem key={text} className="basis-full md:basis-1/2">
              <div className="border p-8">{text}</div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious aria-label="이전 카드" />
        <CarouselNext aria-label="다음 카드" />
      </Carousel>
    </section>
  );
}

UI 파일 범위: ZIP의 src/components/ui는 shadcn CLI 생성물이 아닙니다. 레지스트리 접속 실패로 Radix·Sonner·Embla를 사용하는 축약 학습용 구현을 작성했습니다. 공식 설치 절차는 별도 프로젝트에서 비교하세요. 공식 컴포넌트 전체 기능과 동일하다고 보장하지 않습니다.

검증 범위

Node.js 24.19.0, React 19.3.0, React Router 7.18.3, Zustand 5.0.15, TanStack Query 5.102.8, Tailwind CSS 4.3.3에서 타입 검사와 Vite 빌드를 확인했습니다. 자동 테스트는 jsdom 기반입니다. 실제 브라우저의 포커스·레이아웃·화면낭독기와 모든 네트워크 경쟁 상황은 별도 확인 대상입니다.

npm test
npm run build

공식 문서

2026-09-14 확인. 공식 최신 문서의 버전과 실습 고정 버전이 다를 수 있으므로 설치 버전은 ZIP을 기준으로 비교합니다.

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성 현재 글

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기