-
Radix-ui 란?(feat.사용법)카테고리 없음 2025. 2. 11. 13:13
서론
https://www.radix-ui.com/ 왜 radix-ui인가?
백, 디자인, 프론트와 함께 진행하는 프로젝트의 시작과 동시에, 빠른 시일 내에 개발을 진행해야했다.
현재 마우스로 그린 완전 초기의 와이어프레임만 존재하는 지금 프론트는 무엇을 해야하는가에 대한 고민의 시작이였다.
디자인도 없고, ERD를 짜고있는 시점임에도 불구하고 기간 내 완성을 위함에 따라, 사용이 예상되는 컴포넌트들의 작업이 빠르게 필요했다. 그렇지만, 상상력을 동원해서 '아 이 컴포넌트는 왠지 이런모양이겠지, 아마 이건 이렇게 동작될거야' 라는 협업에 위배되는 사항보다, 디자인을 염두하고 기능을 먼저 개발할 수 있는 방법이 필요했고 채택된것이 Radix-ui이다.
Radix-ui란?
Radix UI는 대표적인 Headless UI 라이브러리로, 접근성과 키보드 네비게이션이 잘 구현된 저수준 UI 컴포넌트들을 제공한다.
키보드 네비게이션?
"키보드만으로 UI를 조작할 수 있는 기능"
마우스를 쓰지 않고 Tab, Enter, Arrow keys(←↑→↓) 같은 키보드 입력으로 UI를 조작할 수 있도록 만들어진 형태
저수준 UI?
스타일이 거의 없고, 기능(동작)만 제공하는 UI 컴포넌트 보통 Headless UI 컴포넌트 라고도 칭함
구분 저수준 (Low-level) 고수준 (High-level) 예시 라이브러리 Radix UI, Headless UI MUI(Material UI), Chakra UI 특징 기능만 제공, 스타일 없음 스타일 포함됨 커스텀 자유도 완전 자유 제한적 Radix-ui는 크게 4가지고 핵심은 아래 2가지로 나뉜다.(썸네일 중앙 상단 카테고리)
- Radix Primitives → 기능적인 UI 컴포넌트 모음 (Headless UI)
- Radix Themes → 디자인 스타일이 적용된 UI 컴포넌트 모음
그 외 :
- Radix Icons → Radix에서 제공하는 오픈소스 SVG 아이콘 컬렉션
- Radix Colors → Radix에서 만든 디자인 시스템용 색상 라이브러리
섹션 언제 사용하면 좋을까? Radix Primitives 스타일 없이 기능만 필요한 경우 (Next.js, Tailwind와 조합) Radix Themes 스타일까지 포함된 UI가 필요할 때 (MUI 같은 느낌) Radix Icons 가볍고 심플한 아이콘이 필요할 때 Radix Colors 일관된 색상 시스템을 구축할 때 (다크 모드 자동 지원) 사용에 앞서, Radix- ui의 introduction 에는 만들어진 배경과 중간에 WAI-ARIA문서에 표준화되어 있다고 설명한다.
WAI-ARIA ?
WAI-ARIA는 웹 콘텐츠와 웹 애플리케이션을 장애가 있는 사용자들도 더 쉽게 접근할 수 있도록 만든 W3C의 기술 사양
더보기- 역할 (role): HTML 요소의 역할을 정의하는 속성
<!-- 예시 --> <div role="button">클릭하세요</div> <div role="dialog">모달 내용</div>
- 상태 (state): 요소의 현재 상태를 나타내는 속성
<!-- 예시 --> <button aria-expanded="false"> <div aria-hidden="true">
- 속성 (property): 요소의 특징이나 상황을 정의하는 속성
<!-- 예시 --> <input aria-required="true" /> <div aria-label="메뉴 버튼">
사용하기
사용에 앞서
- Radix Primitives → 기능적인 UI 컴포넌트 모음 (Headless UI)
- Radix Themes → 디자인 스타일이 적용된 UI 컴포넌트 모음
다음의 두가지를 통해 선정하면된다. Radix Primitives섹션에는 button처럼 HTML의 native 엘리먼트가 충분한 접근성과 기능을 제공한다면 해당 컴포넌트가 따로 없다. Radix Primitives가 필요한 것은 복잡한 컴포넌트들이다.
단순한 컴포넌트는 HTML 엘리먼트 기반으로 구현 복잡한 상호작용이 필요한 컴포넌트는 Radix Primitives 사용 - Button
- Input
- Textarea
- Link
- Image
- Dropdown
- Modal
- Select
- Tooltip
- Accordion
- Tabs
- Popover
- Navigation Menu
- Slider
복잡한 컴포넌트의 차이에 대해서 예시를 들어보자.
어떠한 select를 구현한다고 하자.
기본적으로 디자인의 요소없이 평번한 select라면 아래의 형태가 될 것이다.
// 기본 HTML select로 구현 const BasicSelect = () => ( <select className="border p-2 rounded"> <option value="1">Option 1</option> <option value="2">Option 2</option> </select> );
하지만 추가적으로 아래의 요구사항이 생길 때 문제가 생긴다.
- 디자인 커스터마이징이 필수일 때
- 검색과 같은 추가 기능이 필요할 때
- 완전히 다른 UI/UX가 필요할 때
정리하자면
- 디자인 요구사항 → 기본 select로는 스타일링 한계
- 커스텀 구현 결정 → div, button 등으로 직접 구현
- 여기서 문제 발생:
- 키보드 조작 구현 필요
- 접근성 고려 필요
- 외부 클릭 처리
- 모바일 대응
- Focus 관리 등등...
결국 스타일링 때문에 시작했다가 수많은 기능들을 직접 구현해야 하는 상황에 맞딱드리게된다.
그럼에도 직접 커스텀 select를 구현한다면
const CustomSelect = () => { const [isOpen, setIsOpen] = useState(false); const [selectedValue, setSelectedValue] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const selectRef = useRef<HTMLDivElement>(null); // 외부 클릭 감지 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (selectRef.current && !selectRef.current.contains(event.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // 키보드 네비게이션 const handleKeyDown = (e: React.KeyboardEvent) => { switch(e.key) { case 'ArrowDown': // 다음 아이템으로 이동 로직 break; case 'ArrowUp': // 이전 아이템으로 이동 로직 break; case 'Enter': // 선택 로직 break; case 'Escape': setIsOpen(false); break; } }; return ( <div ref={selectRef} onKeyDown={handleKeyDown} tabIndex={0}> <div onClick={() => setIsOpen(!isOpen)}> {selectedValue || 'Select...'} </div> {isOpen && ( <div className="absolute mt-1 w-full"> <input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> {/* 옵션 목록 */} </div> )} </div> ); };
다음과 같을 것이다. 따라 이러한 상황을 Radix-ui를 사용한다면,
import * as Select from '@radix-ui/react-select'; // 스타일링은 자유롭게 + 기능은 모두 내장 const SelectDemo = () => ( <Select.Root> <Select.Trigger className="your-custom-styles"> <Select.Value /> </Select.Trigger> <Select.Content className="your-custom-styles"> <Select.Item value="1">Option 1</Select.Item> <Select.Item value="2">Option 2</Select.Item> </Select.Content> </Select.Root> );
이러한 형태일 것이다.(여기서 아토믹 패턴임을 확인할 수 있다. )
Radix Ui의 아토믹 패턴
Radix UI는 각 컴포넌트를 작은 단위로 분리하여 조합할 수 있게 만들 수 있다.
아래의 예시로
// Select 컴포넌트의 구조 <Select.Root> // 1. 상태 관리 <Select.Trigger> // 2. 트리거 버튼 <Select.Value /> // 3. 선택된 값 표시 <Select.Icon /> // 4. 아이콘 </Select.Trigger> <Select.Portal> // 5. 포털 (DOM 위치) <Select.Content> // 6. 드롭다운 컨텐츠 <Select.ScrollUpButton/> // 7. 스크롤 버튼 <Select.Viewport> // 8. 뷰포트 <Select.Item> // 9. 아이템 <Select.ItemText/> // 10. 아이템 텍스트 <Select.ItemIndicator/> // 11. 선택 표시자 </Select.Item> </Select.Viewport> </Select.Content> </Select.Portal> </Select.Root>
- 각 부분을 필요에 따라 조합 가능
- 각 컴포넌트별 독립적인 스타일링
- 기능 확장이 용이
- 유지보수가 쉬움
- 재사용성이 높음
과 같은 장점을 가지게 된다.
자 이제 공식문서를 보면서 이해하려고 한다.
https://www.radix-ui.com/primitives/docs/components/alert-dialog
Alert Dialog – Radix Primitives
A modal dialog that interrupts the user with important content and expects a response.
www.radix-ui.com
예시로 사용될 것은 Alert Dialog이다.
해당 섹션에선 코드 예시와 바로아래 Features 란 타이틀이 있을 것이다. 이 Features를 제일 먼저 보면서 이 컴포넌트의 주요 기능을 먼저 파악하는 것이다.
Alert Dialog의 Features
- Focus 자동 관리
- 상태 제어 가능 (controlled/uncontrolled)
- 스크린 리더 지원
- Esc 키로 자동 닫기
'아 이런 기능을 지원하는구나' 를 파악하면 된다.
Alert Dialog의 Installation
이거 복사해서 쓰세용 이란뜻
Alert Dialog의 Anotmy
그리고 두번째 Anatomy에서 해당 컴포넌트의 기본 구조를 파악한다.
자세한 하위 컴포넌트들의(.Root , .Trigger 등등) 정보는 바로아래 API Reference에서 확인 가능하다.
Alert Dialog의 API Reference
API Reference는 Radix UI의 각 컴포넌트가 제공하는 모든 기능과 속성들을 상세하게 문서화한 부분이다. 구조를 파악했다면 아마 이 파트를 가장 오래보게 될 부분이다.
먼저 첫번째 <AlertDialog.Root>를 보자.
Alert Dialog가 처음 렌더링될 때의 열린 상태입니다. 열린 상태를 제어할 필요가 없을 때 사용합니다. 열린 상태를 제어할 필요가 없을 떄 사용한다?
이게 첫 파트 Feature에서 언급한 상태 제어 가능 (controlled/uncontrolled)에 대한 정보이다.
만약 defaultOpen을 사용한다면
// 초기값만 설정하고 이후는 컴포넌트가 자체적으로 상태 관리 <AlertDialog.Root defaultOpen={true}> {/* 컴포넌트가 자체적으로 열고 닫힘을 관리 */} </AlertDialog.Root>
컴포넌트가 자체적으로 열고닫힘 그러니까 setIsOpen을 관리한다는 의미이다.
만약 반대로, 열고 닫힘을 직접 제어한다면
// 모든 상태를 직접 관리 const [isOpen, setIsOpen] = useState(false); <AlertDialog.Root open={isOpen} onOpenChange={setIsOpen} > {/* 열고 닫힘을 직접 제어해야 함 */} </AlertDialog.Root>
아래의 open과 onOpenChagne props를 사용하게 된다.
두 번쨰 <AlertDialog.Trigger>를 보자.
dialog를 여는 버튼 기본 렌더링되는 요소를 자식으로 전달된 요소로 변경하고, 그들의 props와 동작을 병합한다 이 말은 무엇을 의미하냐면,
// 1. asChild 없이 사용 (기본) <Trigger> 열기 // 자동으로 <button>으로 렌더링됨 </Trigger> // 2. asChild 사용 <Trigger asChild> <div>열기</div> // div로 렌더링되면서 Trigger의 기능도 가짐 </Trigger>
다음과 같이 asChild props를 사용하면 그 하위의 요소가 트리거의 기능을 가지게 된다는 의미이다.
그리고 아래의 [data-state]는 트리거 버튼의 현재 상태를 나타내는 데이터 속성으로서 스타일링에 활용 가능하다.
// 스타일링 예시 <style> .trigger[data-state='open'] { background: blue; // 열린 상태일 때 } .trigger[data-state='closed'] { background: gray; // 닫힌 상태일 때 } </style> <AlertDialog.Trigger className="trigger"> 열기 </AlertDialog.Trigger>
세 번째 <AlertDialog.portal>이다.
사용하면, overlay와 content parts를 body 내에서 포탈시킨다(?) portal이란 Portal은 컴포넌트를 DOM 트리의 다른 위치(기본값: document.body)에 렌더링하는 기능이다.
요약하자면 부모의 스타일에 영향을 받지않고 완전 새로위치에서 dom노드로 생긴다는 의미이다.
// 1. Portal 없이 일반적인 DOM 구조 <div class="parent"> 👉 여기에 스타일 적용 (z-index, overflow: hidden 등) <div class="child"> <Dialog> ⚠️ 부모의 스타일에 영향을 받음 모달 내용 </Dialog> </div> </div> // 2. Portal 사용 시 DOM 구조 <div class="parent"> <div class="child"> {/* Dialog가 여기 있지만 */} </div> </div> <body> ... 👉 완전히 새로운 위치에 렌더링! <Dialog> ✅ 부모 스타일에 영향받지 않음 모달 내용 </Dialog> </body>
이렇게 Portal을 사용하면 컴포넌트의 로직은 원래 위치에 둔 채로, 실제 렌더링만 다른 곳(보통 body)에서 일어나게 할 수 있고 z-index문제나 스타일 상속을 방지할 수 있게된다. 특히 모달, 툴팁, 드롭다운 같은 오버레이 UI를 만들 때 사용된다.
forcemount prop은
// 1. 기본 동작 <AlertDialog.Portal> {isOpen && <AlertDialog.Content />} // isOpen이 true일 때만 렌더링 </AlertDialog.Portal> // 2. forceMount 사용 <AlertDialog.Portal forceMount> <AlertDialog.Content /> // 항상 렌더링됨 즉 항상 DOM에 존재 (display: none으로 숨겨질 수 있음) </AlertDialog.Portal>
다음과 같고 주로 애니메이션에 사용된다.
예시
더보기// framer-motion과 함께 사용 function AnimatedDialog() { return ( <AlertDialog.Portal forceMount> <AnimatePresence> {isOpen && ( <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} > <AlertDialog.Content /> </motion.div> )} </AnimatePresence> </AlertDialog.Portal> ); }
container porp은 Portal이 렌더링될 DOM 요소를 지정한다.
예시
function CustomContainer() { const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); return ( <div className="app"> <div className="sidebar"> {/* 사이드바에 모달 렌더링 */} <div ref={setContainerRef} className="modal-container" /> </div> <AlertDialog.Root> <AlertDialog.Trigger>열기</AlertDialog.Trigger> <AlertDialog.Portal container={containerRef}> <AlertDialog.Content> 사이드바 내부에 렌더링됨! </AlertDialog.Content> </AlertDialog.Portal> </AlertDialog.Root> </div> ); }
이런식으로 API Reference를 파악해가면서 prop을 통해 적절하게 원하는 기능을 세팅하면 된다.
radix-ui 스타일링
기본 스타일링 원칙
- Radix Primitives는 기본적으로 스타일이 없는(unstyled) 상태로 제공됩니다
- 모든 스타일링 방식(CSS, CSS-in-JS 등)과 호환됩니다
- 기능적인 스타일(예: Dialog가 화면을 덮는 것)도 직접 구현해야 합니다
스타일링하는 방법은 여러가지가 있다.
1. Tailwind Utility Class 직접 적용
radix UI의 기본 스타일을 유지하면서 Tailwind의 클래스를 직접 적용
import * as Switch from "@radix-ui/react-switch"; export function Toggle() { return ( <Switch.Root className="w-12 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500 transition"> <Switch.Thumb className="block w-5 h-5 bg-white rounded-full shadow transform translate-x-1 data-[state=checked]:translate-x-6 transition" /> </Switch.Root> ); }
장점은 간단하지만, 단점으론 요소마다 스타일을 개별 적용해야해서 유지보수가 어렵다.
2. Tailwind + clsx 사용\
import * as Switch from "@radix-ui/react-switch"; import clsx from "clsx"; export function Toggle() { return ( <Switch.Root className={clsx( "w-12 h-6 rounded-full relative transition", "bg-gray-300 data-[state=checked]:bg-blue-500" )} > <Switch.Thumb className={clsx( "block w-5 h-5 bg-white rounded-full shadow transform transition", "translate-x-1 data-[state=checked]:translate-x-6" )} /> </Switch.Root> ); }
clsx란?
더보기여러 개의 CSS 클래스를 조건부로 조합하는 JavaScript 라이브러리
1. 조건부 클래스
const isActive = true; const button = clsx("bg-gray-500", isActive && "bg-blue-500"); console.log(button); // 출력: "bg-gray-500 bg-blue-500" (isActive가 true일 때)
2. 객체를 이용한 조건부
const isActive = false; const button = clsx("px-4 py-2", { "bg-blue-500 text-white": isActive, "bg-gray-500 text-black": !isActive, }); console.log(button); // 출력: "px-4 py-2 bg-gray-500 text-black" (isActive가 false일 때)
3. 배열을 활용한 클래스 조합
const variant = "success"; const button = clsx("px-4 py-2", [ variant === "success" && "bg-green-500 text-white", variant === "error" && "bg-red-500 text-white", ]); console.log(button); // 출력: "px-4 py-2 bg-green-500 text-white" (variant가 "success"일 때)
4. raddix ui 컴포넌트 스타일링 예시
import * as Switch from "@radix-ui/react-switch"; import clsx from "clsx"; export function Toggle({ checked }: { checked: boolean }) { return ( <Switch.Root className={clsx( "w-12 h-6 rounded-full transition", checked ? "bg-blue-500" : "bg-gray-300" )} > <Switch.Thumb className={clsx( "block w-5 h-5 bg-white rounded-full shadow transform transition", checked ? "translate-x-6" : "translate-x-1" )} /> </Switch.Root> ); }
3. Tailwind + cva (class-variance-authority)
import * as Switch from "@radix-ui/react-switch"; import { cva } from "class-variance-authority"; const switchRoot = cva("w-12 h-6 rounded-full relative transition", { variants: { checked: { true: "bg-blue-500", false: "bg-gray-300", }, }, }); const switchThumb = cva("block w-5 h-5 bg-white rounded-full shadow transform transition", { variants: { checked: { true: "translate-x-6", false: "translate-x-1", }, }, }); export function Toggle() { return ( <Switch.Root className={switchRoot({ checked: true })}> <Switch.Thumb className={switchThumb({ checked: true })} /> </Switch.Root> ); }
cva?
더보기cvaTailwind CSS 스타일을 쉽게 관리하고 재사용 가능한 스타일 변형(variants)을 정의하는 라이브러리
cva() 함수는 기본 클래스를 정의하고, **variant(변형)**를 통해 동적으로 스타일을 변경한다.
variants는 cva에서 스타일을 변형(variants) 할 수 있도록 도와주는 속성이다. 예를 들면 버튼을 "primary", "secondary" 같은 색상별 스타일로 나누거나, "small", "large" 같은 크기별 스타일을 정의할 때 사용한다.
예시
import { cva } from "class-variance-authority"; const button = cva("px-4 py-2 rounded", { variants: { color: { primary: "bg-blue-500 text-white", secondary: "bg-gray-500 text-black", }, size: { sm: "text-sm", md: "text-base", lg: "text-lg", }, }, defaultVariants: { color: "primary", size: "md", }, }); // 사용 예시 console.log(button()); // 기본값 출력: "px-4 py-2 rounded bg-blue-500 text-white text-base" console.log(button({ color: "secondary", size: "lg" })); // 출력: "px-4 py-2 rounded bg-gray-500 text-black text-lg"
✅ variants 속성으로 여러 상태를 정의할 수 있음
✅ defaultVariants를 사용해 기본값을 설정할 수 있음또한 compoundVariants 속성을 이용해서 특정조건을 만족시킬시, 추가적인 스타일을 부여할 수 있다.
예시
const button = cva("px-4 py-2 rounded", { variants: { color: { primary: "bg-blue-500 text-white", secondary: "bg-gray-500 text-black", }, disabled: { true: "opacity-50 cursor-not-allowed", false: "", }, }, compoundVariants: [ { color: "primary", disabled: true, className: "bg-blue-300 text-gray-100", }, { color: "secondary", disabled: true, className: "bg-gray-300 text-gray-500", }, ], defaultVariants: { color: "primary", disabled: false, }, }); // 사용 예시 console.log(button({ color: "primary", disabled: true })); // 출력: "px-4 py-2 rounded opacity-50 cursor-not-allowed bg-blue-300 text-gray-100"
++) shadcn/ui에서 컴포넌트를 설치하면 cva를 활용해서 스타일 변형을 관리한다.
shadcn/ui badge컴포넌트.
import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, } ) export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} function Badge({ className, variant, ...props }: BadgeProps) { return ( <div className={cn(badgeVariants({ variant }), className)} {...props} /> ) } export { Badge, badgeVariants }
++) AlertDialog 공통컴포넌트 실사용 예시
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; interface AlertDialogProps { open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: string; cancelText?: string; actionText?: string; onAction?: () => void; className?: string; size?: "sm" | "md" | "lg"; variant?: "default" | "danger" | "success"; } const sizeClasses = { sm: "max-w-sm", md: "max-w-md", lg: "max-w-lg", }; const variantClasses = { default: "bg-white dark:bg-gray-800", danger: "bg-red-100 dark:bg-red-800", success: "bg-green-100 dark:bg-green-800", }; export function AlertDialog({ open, onOpenChange, title, description, cancelText = "Cancel", actionText = "Confirm", onAction, className, size = "md", variant = "default", }: AlertDialogProps) { return ( <AlertDialogPrimitive.Root open={open} onOpenChange={onOpenChange}> <AlertDialogPrimitive.Portal> <AlertDialogPrimitive.Overlay className="fixed inset-0 bg-black/50" /> <AlertDialogPrimitive.Content className={cn( "fixed left-1/2 top-1/2 w-[90%] -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg", sizeClasses[size], variantClasses[variant], className )} > <AlertDialogPrimitive.Title className="text-lg font-semibold"> {title} </AlertDialogPrimitive.Title> {description && ( <AlertDialogPrimitive.Description className="mt-2 text-sm text-gray-600 dark:text-gray-300"> {description} </AlertDialogPrimitive.Description> )} <div className="mt-4 flex justify-end gap-2"> <AlertDialogPrimitive.Cancel asChild> <Button variant="outline">{cancelText}</Button> </AlertDialogPrimitive.Cancel> <AlertDialogPrimitive.Action asChild> <Button onClick={onAction}>{actionText}</Button> </AlertDialogPrimitive.Action> </div> </AlertDialogPrimitive.Content> </AlertDialogPrimitive.Portal> </AlertDialogPrimitive.Root> ); }