Radix UI - Fundament Dostępnych Komponentów React
Czym jest Radix UI?
Radix UI to biblioteka nieostylowanych, w pełni dostępnych komponentów React (primitives). Jest fundamentem popularnej biblioteki shadcn/ui i wielu innych nowoczesnych systemów UI. Radix dostarcza wszystkie skomplikowane zachowania (keyboard navigation, focus management, ARIA attributes) bez narzucania stylów - masz pełną kontrolę nad wyglądem.
Dlaczego Radix UI?
Problemy z tradycyjnymi bibliotekami UI
Tradycyjne biblioteki komponentów (Material UI, Ant Design, Bootstrap) mają wspólne problemy:
- Narzucone style - Trudno dostosować do własnego designu
- Duży bundle size - Nawet jeśli używasz jednego komponentu
- Niepełna dostępność - Często pomijane edge cases
- Ograniczona kompozycja - Sztywna struktura komponentów
Zalety Radix UI
- Pełna dostępność (a11y) - WAI-ARIA compliant, testowane z screen readerami
- Nieostylowane - 100% kontroli nad CSS, zero konfliktów
- Kompozycyjne API - Elastyczne budowanie UI z prymitywów
- Małe rozmiary - Importuj tylko to, czego potrzebujesz
- Animacje - Pełne wsparcie dla Framer Motion i CSS animations
- SSR - Działa z Next.js out of the box
Instalacja
Pojedyncze komponenty
# Każdy prymityw instalujemy osobno
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-popover
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-tabs
npm install @radix-ui/react-accordion
npm install @radix-ui/react-switch
npm install @radix-ui/react-checkbox
npm install @radix-ui/react-select
npm install @radix-ui/react-sliderIkony (opcjonalnie)
npm install @radix-ui/react-iconsDialog (Modal)
Dialog to jeden z najczęściej używanych komponentów. Radix obsługuje wszystkie edge cases:
// components/Modal.tsx
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
interface ModalProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: React.ReactNode
}
export function Modal({
open,
onOpenChange,
title,
description,
children,
}: ModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
{/* Overlay - tło za modalem */}
<Dialog.Overlay
className="fixed inset-0 bg-black/50 backdrop-blur-sm
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
{/* Content - sam modal */}
<Dialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-full max-w-lg bg-white dark:bg-gray-900
rounded-xl shadow-xl p-6
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
>
{/* Nagłówek */}
<Dialog.Title className="text-xl font-semibold mb-2">
{title}
</Dialog.Title>
{description && (
<Dialog.Description className="text-gray-500 mb-4">
{description}
</Dialog.Description>
)}
{/* Zawartość */}
<div className="mt-4">
{children}
</div>
{/* Przycisk zamknięcia */}
<Dialog.Close asChild>
<button
className="absolute top-4 right-4 p-1 rounded-full
hover:bg-gray-100 dark:hover:bg-gray-800
focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Zamknij"
>
<X className="w-5 h-5" />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
// Przykład użycia
function Example() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>
Otwórz modal
</button>
<Modal
open={open}
onOpenChange={setOpen}
title="Potwierdź akcję"
description="Czy na pewno chcesz kontynuować?"
>
<div className="flex gap-3 justify-end">
<Dialog.Close asChild>
<button className="px-4 py-2 rounded bg-gray-100">
Anuluj
</button>
</Dialog.Close>
<button
className="px-4 py-2 rounded bg-blue-500 text-white"
onClick={() => {
// Akcja
setOpen(false)
}}
>
Potwierdź
</button>
</div>
</Modal>
</>
)
}Dialog z triggerem
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Otwórz
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<Dialog.Title>Tytuł</Dialog.Title>
<Dialog.Description>Opis...</Dialog.Description>
<Dialog.Close asChild>
<button>Zamknij</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>Dropdown Menu
Kompleksowe menu rozwijane z pełną obsługą klawiatury:
// components/DropdownMenu.tsx
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {
User,
Settings,
CreditCard,
LogOut,
ChevronRight,
Check
} from 'lucide-react'
interface UserMenuProps {
user: {
name: string
email: string
avatar: string
}
onLogout: () => void
}
export function UserMenu({ user, onLogout }: UserMenuProps) {
const [theme, setTheme] = useState('system')
const [notifications, setNotifications] = useState(true)
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center gap-2 p-2 rounded-full hover:bg-gray-100">
<img
src={user.avatar}
alt={user.name}
className="w-8 h-8 rounded-full"
/>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white dark:bg-gray-900
rounded-lg shadow-lg border border-gray-200 dark:border-gray-800
p-1.5 animate-in fade-in-0 zoom-in-95"
sideOffset={8}
align="end"
>
{/* Nagłówek z informacjami o użytkowniku */}
<DropdownMenu.Label className="px-2 py-1.5">
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</DropdownMenu.Label>
<DropdownMenu.Separator className="h-px bg-gray-200 dark:bg-gray-800 my-1" />
{/* Zwykłe elementy menu */}
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none
hover:bg-gray-100 dark:hover:bg-gray-800
focus:bg-gray-100 dark:focus:bg-gray-800"
>
<User className="w-4 h-4" />
<span>Profil</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100 focus:bg-gray-100">
<CreditCard className="w-4 h-4" />
<span>Płatności</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100 focus:bg-gray-100">
<Settings className="w-4 h-4" />
<span>Ustawienia</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
{/* Podmenu */}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
className="flex items-center justify-between px-2 py-1.5 rounded
cursor-pointer outline-none
hover:bg-gray-100 focus:bg-gray-100
data-[state=open]:bg-gray-100"
>
<span>Motyw</span>
<ChevronRight className="w-4 h-4" />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className="min-w-[160px] bg-white rounded-lg shadow-lg border p-1.5"
sideOffset={4}
>
<DropdownMenu.RadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenu.RadioItem
value="light"
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">Jasny</span>
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="dark"
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">Ciemny</span>
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="system"
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">System</span>
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
{/* Checkbox item */}
<DropdownMenu.CheckboxItem
checked={notifications}
onCheckedChange={setNotifications}
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">Powiadomienia</span>
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
{/* Logout z custom styling */}
<DropdownMenu.Item
onClick={onLogout}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none
text-red-600 hover:bg-red-50 focus:bg-red-50"
>
<LogOut className="w-4 h-4" />
<span>Wyloguj się</span>
</DropdownMenu.Item>
{/* Strzałka wskazująca na trigger */}
<DropdownMenu.Arrow className="fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}Popover
Idealne do tooltipów, edycji inline i floating panels:
// components/Popover.tsx
import * as Popover from '@radix-ui/react-popover'
import { Settings, X } from 'lucide-react'
export function SettingsPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Ustawienia"
>
<Settings className="w-5 h-5" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="w-80 bg-white dark:bg-gray-900 rounded-lg shadow-xl
border border-gray-200 dark:border-gray-800 p-4
animate-in fade-in-0 zoom-in-95
data-[side=bottom]:slide-in-from-top-2
data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2
data-[side=top]:slide-in-from-bottom-2"
sideOffset={8}
>
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold">Ustawienia wyświetlania</h3>
<Popover.Close asChild>
<button className="p-1 rounded hover:bg-gray-100">
<X className="w-4 h-4" />
</button>
</Popover.Close>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
Rozmiar tekstu
</label>
<select className="w-full px-3 py-2 border rounded-lg">
<option>Mały</option>
<option>Średni</option>
<option>Duży</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Kontrast
</label>
<input
type="range"
min="0"
max="100"
className="w-full"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Tryb ciemny</span>
<Switch />
</div>
</div>
<Popover.Arrow className="fill-white" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}Tooltip
Lekkie tooltips z pełną obsługą dostępności:
// components/Tooltip.tsx
import * as Tooltip from '@radix-ui/react-tooltip'
interface TooltipWrapperProps {
content: string
children: React.ReactNode
side?: 'top' | 'right' | 'bottom' | 'left'
delayDuration?: number
}
export function TooltipWrapper({
content,
children,
side = 'top',
delayDuration = 200,
}: TooltipWrapperProps) {
return (
<Tooltip.Provider delayDuration={delayDuration}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{children}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={side}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded-lg
animate-in fade-in-0 zoom-in-95
data-[side=bottom]:slide-in-from-top-2
data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2
data-[side=top]:slide-in-from-bottom-2"
sideOffset={4}
>
{content}
<Tooltip.Arrow className="fill-gray-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Użycie
function Example() {
return (
<TooltipWrapper content="Kliknij, aby skopiować">
<button className="p-2 rounded hover:bg-gray-100">
<Copy className="w-5 h-5" />
</button>
</TooltipWrapper>
)
}Provider na poziomie aplikacji
// app/providers.tsx
import * as Tooltip from '@radix-ui/react-tooltip'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={200} skipDelayDuration={500}>
{children}
</Tooltip.Provider>
)
}Tabs
Zakładki z pełną obsługą klawiatury (strzałki, Home, End):
// components/Tabs.tsx
import * as Tabs from '@radix-ui/react-tabs'
interface Tab {
value: string
label: string
content: React.ReactNode
disabled?: boolean
}
interface TabsProps {
tabs: Tab[]
defaultValue?: string
}
export function TabsComponent({ tabs, defaultValue }: TabsProps) {
return (
<Tabs.Root
defaultValue={defaultValue || tabs[0]?.value}
className="w-full"
>
<Tabs.List
className="flex border-b border-gray-200"
aria-label="Zarządzaj kontem"
>
{tabs.map((tab) => (
<Tabs.Trigger
key={tab.value}
value={tab.value}
disabled={tab.disabled}
className="px-4 py-2 text-sm font-medium
border-b-2 border-transparent
text-gray-500 hover:text-gray-700
data-[state=active]:border-blue-500
data-[state=active]:text-blue-600
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{tab.label}
</Tabs.Trigger>
))}
</Tabs.List>
{tabs.map((tab) => (
<Tabs.Content
key={tab.value}
value={tab.value}
className="pt-4 focus:outline-none"
>
{tab.content}
</Tabs.Content>
))}
</Tabs.Root>
)
}
// Użycie
function AccountSettings() {
const tabs = [
{
value: 'profile',
label: 'Profil',
content: <ProfileForm />,
},
{
value: 'security',
label: 'Bezpieczeństwo',
content: <SecuritySettings />,
},
{
value: 'notifications',
label: 'Powiadomienia',
content: <NotificationSettings />,
},
{
value: 'billing',
label: 'Płatności',
content: <BillingInfo />,
disabled: true, // Niedostępne
},
]
return <TabsComponent tabs={tabs} defaultValue="profile" />
}Vertical Tabs
<Tabs.Root
defaultValue="tab1"
orientation="vertical"
className="flex gap-4"
>
<Tabs.List className="flex flex-col w-48 border-r">
<Tabs.Trigger value="tab1" className="px-4 py-2 text-left data-[state=active]:bg-gray-100">
Tab 1
</Tabs.Trigger>
<Tabs.Trigger value="tab2" className="px-4 py-2 text-left data-[state=active]:bg-gray-100">
Tab 2
</Tabs.Trigger>
</Tabs.List>
<div className="flex-1">
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</div>
</Tabs.Root>Accordion
Rozwijane sekcje z animacjami:
// components/Accordion.tsx
import * as Accordion from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react'
interface AccordionItem {
value: string
trigger: string
content: React.ReactNode
}
interface AccordionProps {
items: AccordionItem[]
type?: 'single' | 'multiple'
defaultValue?: string | string[]
}
export function AccordionComponent({
items,
type = 'single',
defaultValue,
}: AccordionProps) {
return (
<Accordion.Root
type={type}
defaultValue={defaultValue}
collapsible={type === 'single'}
className="w-full"
>
{items.map((item) => (
<Accordion.Item
key={item.value}
value={item.value}
className="border-b border-gray-200"
>
<Accordion.Header>
<Accordion.Trigger
className="flex w-full items-center justify-between
py-4 font-medium text-left
hover:underline
[&[data-state=open]>svg]:rotate-180"
>
{item.trigger}
<ChevronDown
className="w-5 h-5 text-gray-500 transition-transform duration-200"
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
className="overflow-hidden
data-[state=closed]:animate-accordion-up
data-[state=open]:animate-accordion-down"
>
<div className="pb-4 text-gray-600">
{item.content}
</div>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
)
}
// Tailwind config dla animacji
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
}Select
Pełny select z wyszukiwaniem, grupami i keyboard navigation:
// components/Select.tsx
import * as Select from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
interface SelectOption {
value: string
label: string
disabled?: boolean
}
interface SelectGroup {
label: string
options: SelectOption[]
}
interface SelectProps {
placeholder?: string
value?: string
onValueChange: (value: string) => void
groups: SelectGroup[]
}
export function SelectComponent({
placeholder = 'Wybierz...',
value,
onValueChange,
groups,
}: SelectProps) {
return (
<Select.Root value={value} onValueChange={onValueChange}>
<Select.Trigger
className="flex items-center justify-between w-full px-3 py-2
border border-gray-300 rounded-lg bg-white
focus:outline-none focus:ring-2 focus:ring-blue-500
data-[placeholder]:text-gray-400"
>
<Select.Value placeholder={placeholder} />
<Select.Icon>
<ChevronDown className="w-4 h-4 text-gray-500" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
className="overflow-hidden bg-white rounded-lg shadow-lg border
animate-in fade-in-0 zoom-in-95"
position="popper"
sideOffset={4}
>
<Select.ScrollUpButton className="flex items-center justify-center h-6 bg-white">
<ChevronUp className="w-4 h-4" />
</Select.ScrollUpButton>
<Select.Viewport className="p-1">
{groups.map((group, groupIndex) => (
<Select.Group key={group.label}>
{groupIndex > 0 && (
<Select.Separator className="h-px bg-gray-200 my-1" />
)}
<Select.Label className="px-2 py-1.5 text-xs font-medium text-gray-500">
{group.label}
</Select.Label>
{group.options.map((option) => (
<Select.Item
key={option.value}
value={option.value}
disabled={option.disabled}
className="relative flex items-center px-8 py-2 rounded
cursor-pointer outline-none
hover:bg-gray-100 focus:bg-gray-100
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
>
<Select.ItemIndicator className="absolute left-2">
<Check className="w-4 h-4" />
</Select.ItemIndicator>
<Select.ItemText>{option.label}</Select.ItemText>
</Select.Item>
))}
</Select.Group>
))}
</Select.Viewport>
<Select.ScrollDownButton className="flex items-center justify-center h-6 bg-white">
<ChevronDown className="w-4 h-4" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
// Użycie
function CountrySelect() {
const [country, setCountry] = useState('')
const groups = [
{
label: 'Europa',
options: [
{ value: 'pl', label: 'Polska' },
{ value: 'de', label: 'Niemcy' },
{ value: 'fr', label: 'Francja' },
],
},
{
label: 'Ameryka',
options: [
{ value: 'us', label: 'Stany Zjednoczone' },
{ value: 'ca', label: 'Kanada' },
],
},
]
return (
<SelectComponent
placeholder="Wybierz kraj"
value={country}
onValueChange={setCountry}
groups={groups}
/>
)
}Switch
Toggle switch z animacją:
// components/Switch.tsx
import * as Switch from '@radix-ui/react-switch'
interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
label?: string
disabled?: boolean
}
export function SwitchComponent({
checked,
onCheckedChange,
label,
disabled = false,
}: SwitchProps) {
return (
<div className="flex items-center gap-3">
<Switch.Root
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className="w-11 h-6 bg-gray-200 rounded-full
relative cursor-pointer
data-[state=checked]:bg-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Switch.Thumb
className="block w-5 h-5 bg-white rounded-full shadow
transition-transform duration-200
translate-x-0.5 data-[state=checked]:translate-x-[22px]"
/>
</Switch.Root>
{label && (
<label className="text-sm font-medium">
{label}
</label>
)}
</div>
)
}
// Użycie
function NotificationSettings() {
const [emailNotifications, setEmailNotifications] = useState(true)
const [pushNotifications, setPushNotifications] = useState(false)
return (
<div className="space-y-4">
<SwitchComponent
checked={emailNotifications}
onCheckedChange={setEmailNotifications}
label="Powiadomienia email"
/>
<SwitchComponent
checked={pushNotifications}
onCheckedChange={setPushNotifications}
label="Powiadomienia push"
/>
</div>
)
}Checkbox
Checkbox z indeterminate state:
// components/Checkbox.tsx
import * as Checkbox from '@radix-ui/react-checkbox'
import { Check, Minus } from 'lucide-react'
interface CheckboxProps {
checked: boolean | 'indeterminate'
onCheckedChange: (checked: boolean | 'indeterminate') => void
label: string
description?: string
}
export function CheckboxComponent({
checked,
onCheckedChange,
label,
description,
}: CheckboxProps) {
return (
<div className="flex items-start gap-3">
<Checkbox.Root
checked={checked}
onCheckedChange={onCheckedChange}
className="w-5 h-5 border-2 border-gray-300 rounded
flex items-center justify-center
data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500
data-[state=indeterminate]:bg-blue-500 data-[state=indeterminate]:border-blue-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Checkbox.Indicator>
{checked === 'indeterminate' ? (
<Minus className="w-3 h-3 text-white" />
) : (
<Check className="w-3 h-3 text-white" />
)}
</Checkbox.Indicator>
</Checkbox.Root>
<div>
<label className="text-sm font-medium cursor-pointer">
{label}
</label>
{description && (
<p className="text-sm text-gray-500">{description}</p>
)}
</div>
</div>
)
}
// Lista z "Select All"
function CheckboxList() {
const [items, setItems] = useState([
{ id: '1', label: 'Element 1', checked: false },
{ id: '2', label: 'Element 2', checked: true },
{ id: '3', label: 'Element 3', checked: false },
])
const allChecked = items.every(item => item.checked)
const someChecked = items.some(item => item.checked)
const selectAllState = allChecked ? true : someChecked ? 'indeterminate' : false
const handleSelectAll = (checked: boolean | 'indeterminate') => {
if (checked === 'indeterminate') return
setItems(items.map(item => ({ ...item, checked })))
}
const handleItemChange = (id: string, checked: boolean | 'indeterminate') => {
if (checked === 'indeterminate') return
setItems(items.map(item =>
item.id === id ? { ...item, checked } : item
))
}
return (
<div className="space-y-3">
<CheckboxComponent
checked={selectAllState}
onCheckedChange={handleSelectAll}
label="Zaznacz wszystkie"
/>
<div className="ml-6 space-y-2">
{items.map(item => (
<CheckboxComponent
key={item.id}
checked={item.checked}
onCheckedChange={(checked) => handleItemChange(item.id, checked)}
label={item.label}
/>
))}
</div>
</div>
)
}Slider
Suwak z wieloma wartościami:
// components/Slider.tsx
import * as Slider from '@radix-ui/react-slider'
interface SliderProps {
value: number[]
onValueChange: (value: number[]) => void
min?: number
max?: number
step?: number
label?: string
}
export function SliderComponent({
value,
onValueChange,
min = 0,
max = 100,
step = 1,
label,
}: SliderProps) {
return (
<div className="w-full">
{label && (
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">{label}</span>
<span className="text-sm text-gray-500">{value.join(' - ')}</span>
</div>
)}
<Slider.Root
value={value}
onValueChange={onValueChange}
min={min}
max={max}
step={step}
className="relative flex items-center w-full h-5 select-none"
>
<Slider.Track className="relative h-2 w-full grow rounded-full bg-gray-200">
<Slider.Range className="absolute h-full rounded-full bg-blue-500" />
</Slider.Track>
{value.map((_, index) => (
<Slider.Thumb
key={index}
className="block w-5 h-5 bg-white border-2 border-blue-500 rounded-full
shadow focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
hover:bg-blue-50"
/>
))}
</Slider.Root>
</div>
)
}
// Użycie - pojedynczy suwak
function VolumeControl() {
const [volume, setVolume] = useState([50])
return (
<SliderComponent
value={volume}
onValueChange={setVolume}
label="Głośność"
/>
)
}
// Użycie - zakres
function PriceRange() {
const [range, setRange] = useState([20, 80])
return (
<SliderComponent
value={range}
onValueChange={setRange}
min={0}
max={1000}
step={10}
label="Zakres cen (PLN)"
/>
)
}Navigation Menu
Zaawansowane menu nawigacyjne (jak na dużych stronach):
// components/NavigationMenu.tsx
import * as NavigationMenu from '@radix-ui/react-navigation-menu'
import { ChevronDown } from 'lucide-react'
export function MainNavigation() {
return (
<NavigationMenu.Root className="relative z-10">
<NavigationMenu.List className="flex items-center gap-1 p-1">
{/* Link bez submenu */}
<NavigationMenu.Item>
<NavigationMenu.Link
href="/"
className="px-3 py-2 rounded-md text-sm font-medium
hover:bg-gray-100 focus:outline-none focus:ring-2"
>
Strona główna
</NavigationMenu.Link>
</NavigationMenu.Item>
{/* Produkty z mega menu */}
<NavigationMenu.Item>
<NavigationMenu.Trigger
className="flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium
hover:bg-gray-100 focus:outline-none focus:ring-2
data-[state=open]:bg-gray-100"
>
Produkty
<ChevronDown className="w-4 h-4 transition-transform data-[state=open]:rotate-180" />
</NavigationMenu.Trigger>
<NavigationMenu.Content
className="absolute top-full left-0 w-full
data-[motion=from-start]:animate-enterFromLeft
data-[motion=from-end]:animate-enterFromRight
data-[motion=to-start]:animate-exitToLeft
data-[motion=to-end]:animate-exitToRight"
>
<div className="grid grid-cols-3 gap-4 p-6 bg-white shadow-lg rounded-lg border">
<div>
<h3 className="font-semibold mb-3">Dla Developerów</h3>
<ul className="space-y-2">
<li><a href="/api" className="text-sm hover:text-blue-500">API</a></li>
<li><a href="/sdk" className="text-sm hover:text-blue-500">SDK</a></li>
<li><a href="/docs" className="text-sm hover:text-blue-500">Dokumentacja</a></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-3">Dla Biznesu</h3>
<ul className="space-y-2">
<li><a href="/enterprise" className="text-sm hover:text-blue-500">Enterprise</a></li>
<li><a href="/pricing" className="text-sm hover:text-blue-500">Cennik</a></li>
<li><a href="/case-studies" className="text-sm hover:text-blue-500">Case Studies</a></li>
</ul>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold mb-2">Nowość!</h3>
<p className="text-sm text-gray-600 mb-3">
Sprawdź nasz najnowszy produkt - AI Assistant.
</p>
<a
href="/ai-assistant"
className="text-sm text-blue-500 font-medium"
>
Dowiedz się więcej →
</a>
</div>
</div>
</NavigationMenu.Content>
</NavigationMenu.Item>
{/* Zasoby z listą */}
<NavigationMenu.Item>
<NavigationMenu.Trigger
className="flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium
hover:bg-gray-100"
>
Zasoby
<ChevronDown className="w-4 h-4" />
</NavigationMenu.Trigger>
<NavigationMenu.Content className="absolute top-full left-0">
<ul className="w-48 p-2 bg-white shadow-lg rounded-lg border">
<li>
<NavigationMenu.Link
href="/blog"
className="block px-3 py-2 rounded hover:bg-gray-100 text-sm"
>
Blog
</NavigationMenu.Link>
</li>
<li>
<NavigationMenu.Link
href="/tutorials"
className="block px-3 py-2 rounded hover:bg-gray-100 text-sm"
>
Tutoriale
</NavigationMenu.Link>
</li>
<li>
<NavigationMenu.Link
href="/changelog"
className="block px-3 py-2 rounded hover:bg-gray-100 text-sm"
>
Changelog
</NavigationMenu.Link>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Indicator
className="top-full z-10 flex h-2.5 items-end justify-center overflow-hidden
data-[state=visible]:animate-in data-[state=visible]:fade-in
data-[state=hidden]:animate-out data-[state=hidden]:fade-out"
>
<div className="relative top-1/2 h-2 w-2 rotate-45 bg-white border" />
</NavigationMenu.Indicator>
</NavigationMenu.List>
<NavigationMenu.Viewport
className="absolute top-full left-0 flex w-full justify-center"
/>
</NavigationMenu.Root>
)
}Context Menu
Menu kontekstowe (prawy przycisk myszy):
// components/ContextMenu.tsx
import * as ContextMenu from '@radix-ui/react-context-menu'
import { Copy, Trash, Edit, Share } from 'lucide-react'
interface FileContextMenuProps {
children: React.ReactNode
onCopy: () => void
onEdit: () => void
onDelete: () => void
onShare: () => void
}
export function FileContextMenu({
children,
onCopy,
onEdit,
onDelete,
onShare,
}: FileContextMenuProps) {
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
{children}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
className="min-w-[180px] bg-white rounded-lg shadow-lg border p-1
animate-in fade-in-0 zoom-in-95"
>
<ContextMenu.Item
onClick={onCopy}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none hover:bg-gray-100"
>
<Copy className="w-4 h-4" />
Kopiuj
<ContextMenu.Shortcut className="ml-auto text-xs text-gray-400">
⌘C
</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Item
onClick={onEdit}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none hover:bg-gray-100"
>
<Edit className="w-4 h-4" />
Edytuj
<ContextMenu.Shortcut className="ml-auto text-xs text-gray-400">
⌘E
</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Item
onClick={onShare}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none hover:bg-gray-100"
>
<Share className="w-4 h-4" />
Udostępnij
</ContextMenu.Item>
<ContextMenu.Separator className="h-px bg-gray-200 my-1" />
<ContextMenu.Item
onClick={onDelete}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none text-red-600 hover:bg-red-50"
>
<Trash className="w-4 h-4" />
Usuń
<ContextMenu.Shortcut className="ml-auto text-xs">
⌫
</ContextMenu.Shortcut>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
)
}
// Użycie
function FileItem({ file }: { file: File }) {
return (
<FileContextMenu
onCopy={() => console.log('Copy', file.name)}
onEdit={() => console.log('Edit', file.name)}
onDelete={() => console.log('Delete', file.name)}
onShare={() => console.log('Share', file.name)}
>
<div className="p-4 border rounded hover:bg-gray-50 cursor-pointer">
<span>{file.name}</span>
</div>
</FileContextMenu>
)
}Alert Dialog
Dialog potwierdzający destrukcyjne akcje:
// components/AlertDialog.tsx
import * as AlertDialog from '@radix-ui/react-alert-dialog'
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
variant?: 'danger' | 'warning'
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Potwierdź',
cancelLabel = 'Anuluj',
onConfirm,
variant = 'danger',
}: ConfirmDialogProps) {
const confirmButtonClass = variant === 'danger'
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-yellow-500 hover:bg-yellow-600 text-white'
return (
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay
className="fixed inset-0 bg-black/50 backdrop-blur-sm
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-full max-w-md bg-white rounded-xl shadow-xl p-6
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>
<AlertDialog.Title className="text-xl font-semibold">
{title}
</AlertDialog.Title>
<AlertDialog.Description className="mt-2 text-gray-500">
{description}
</AlertDialog.Description>
<div className="flex justify-end gap-3 mt-6">
<AlertDialog.Cancel asChild>
<button className="px-4 py-2 rounded-lg border hover:bg-gray-50">
{cancelLabel}
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-lg ${confirmButtonClass}`}
>
{confirmLabel}
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
// Użycie
function DeleteButton() {
const [open, setOpen] = useState(false)
const handleDelete = () => {
// Wykonaj usunięcie
console.log('Deleted!')
setOpen(false)
}
return (
<>
<button
onClick={() => setOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded"
>
Usuń konto
</button>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Czy na pewno chcesz usunąć konto?"
description="Ta akcja jest nieodwracalna. Wszystkie Twoje dane zostaną permanentnie usunięte."
confirmLabel="Usuń konto"
onConfirm={handleDelete}
variant="danger"
/>
</>
)
}Animacje z Framer Motion
Radix świetnie współpracuje z Framer Motion:
import * as Dialog from '@radix-ui/react-dialog'
import { motion, AnimatePresence } from 'framer-motion'
import { forwardRef } from 'react'
// Wrapper dla motion z Radix
const MotionOverlay = motion(
forwardRef<HTMLDivElement, Dialog.DialogOverlayProps>((props, ref) => (
<Dialog.Overlay ref={ref} {...props} />
))
)
const MotionContent = motion(
forwardRef<HTMLDivElement, Dialog.DialogContentProps>((props, ref) => (
<Dialog.Content ref={ref} {...props} />
))
)
interface AnimatedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
export function AnimatedDialog({ open, onOpenChange, children }: AnimatedDialogProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<MotionOverlay
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
<MotionContent
className="fixed left-1/2 top-1/2 w-full max-w-lg bg-white rounded-xl p-6"
initial={{
opacity: 0,
scale: 0.95,
x: '-50%',
y: '-50%',
}}
animate={{
opacity: 1,
scale: 1,
x: '-50%',
y: '-50%',
}}
exit={{
opacity: 0,
scale: 0.95,
x: '-50%',
y: '-50%',
}}
transition={{
type: 'spring',
damping: 25,
stiffness: 300
}}
>
{children}
</MotionContent>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
)
}Lista wszystkich prymitywów Radix
| Komponent | Opis | Użycie |
|---|---|---|
| Dialog | Modal/popup | Formularze, potwierdzenia |
| Alert Dialog | Dialog potwierdzający | Destrukcyjne akcje |
| Dropdown Menu | Menu rozwijane | Akcje użytkownika |
| Context Menu | Menu kontekstowe | Prawy przycisk myszy |
| Menubar | Pasek menu | Aplikacje desktop-like |
| Navigation Menu | Menu nawigacyjne | Główna nawigacja |
| Popover | Floating panel | Edycja inline, ustawienia |
| Hover Card | Karta na hover | Preview użytkownika |
| Tooltip | Podpowiedź | Wyjaśnienia UI |
| Accordion | Rozwijane sekcje | FAQ, listy |
| Collapsible | Pojedyncza sekcja | Więcej/mniej |
| Tabs | Zakładki | Nawigacja w widoku |
| Toggle | Przycisk toggle | On/off akcje |
| Toggle Group | Grupa toggle | Radio buttons |
| Select | Dropdown select | Formularze |
| Slider | Suwak | Zakresy wartości |
| Switch | Przełącznik | Boolean settings |
| Checkbox | Checkbox | Multi-select |
| Radio Group | Radio buttons | Single select |
| Avatar | Awatar | Zdjęcie użytkownika |
| Progress | Pasek postępu | Loading, progress |
| Scroll Area | Obszar przewijania | Custom scrollbary |
| Separator | Separator | Wizualne oddzielenie |
| Toolbar | Pasek narzędzi | Akcje edytora |
| Toast | Powiadomienia | Feedback użytkownika |
| Aspect Ratio | Proporcje | Media containers |
| Form | Formularz | Walidacja formularzy |
| Label | Etykieta | Accessibility labels |
| Visually Hidden | Ukryty tekst | Screen reader only |
Podsumowanie
Radix UI to fundament nowoczesnych bibliotek komponentów React. Główne zalety:
- Pełna dostępność - WAI-ARIA compliant, keyboard navigation
- Nieostylowane - 100% kontrola nad wyglądem
- Kompozycyjne API - Elastyczne budowanie UI
- Małe rozmiary - Importuj tylko potrzebne komponenty
- SSR ready - Działa z Next.js out of the box
- Animacje - Pełne wsparcie dla Framer Motion i CSS
Jeśli budujesz własny system UI lub używasz Tailwind CSS, Radix UI jest idealnym wyborem jako fundament komponentów.
Radix UI - The Foundation of Accessible React Components
What is Radix UI?
Radix UI is a library of unstyled, fully accessible React component primitives. It is the foundation of the popular shadcn/ui library and many other modern UI systems. Radix provides all the complex behaviors (keyboard navigation, focus management, ARIA attributes) without imposing any styles - you have complete control over the appearance.
Why Radix UI?
Problems with traditional UI libraries
Traditional component libraries (Material UI, Ant Design, Bootstrap) share common problems:
- Imposed styles - Hard to customize to match your own design
- Large bundle size - Even if you only use a single component
- Incomplete accessibility - Often missing edge cases
- Limited composition - Rigid component structure
Advantages of Radix UI
- Full accessibility (a11y) - WAI-ARIA compliant, tested with screen readers
- Unstyled - 100% control over CSS, zero conflicts
- Composable API - Flexible UI building from primitives
- Small sizes - Import only what you need
- Animations - Full support for Framer Motion and CSS animations
- SSR - Works with Next.js out of the box
Installation
Individual components
# Each primitive is installed separately
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-popover
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-tabs
npm install @radix-ui/react-accordion
npm install @radix-ui/react-switch
npm install @radix-ui/react-checkbox
npm install @radix-ui/react-select
npm install @radix-ui/react-sliderIcons (optional)
npm install @radix-ui/react-iconsDialog (Modal)
Dialog is one of the most commonly used components. Radix handles all edge cases:
// components/Modal.tsx
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
interface ModalProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: React.ReactNode
}
export function Modal({
open,
onOpenChange,
title,
description,
children,
}: ModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
{/* Overlay - background behind the modal */}
<Dialog.Overlay
className="fixed inset-0 bg-black/50 backdrop-blur-sm
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
{/* Content - the modal itself */}
<Dialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-full max-w-lg bg-white dark:bg-gray-900
rounded-xl shadow-xl p-6
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
>
{/* Header */}
<Dialog.Title className="text-xl font-semibold mb-2">
{title}
</Dialog.Title>
{description && (
<Dialog.Description className="text-gray-500 mb-4">
{description}
</Dialog.Description>
)}
{/* Content */}
<div className="mt-4">
{children}
</div>
{/* Close button */}
<Dialog.Close asChild>
<button
className="absolute top-4 right-4 p-1 rounded-full
hover:bg-gray-100 dark:hover:bg-gray-800
focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
// Usage example
function Example() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>
Open modal
</button>
<Modal
open={open}
onOpenChange={setOpen}
title="Confirm action"
description="Are you sure you want to continue?"
>
<div className="flex gap-3 justify-end">
<Dialog.Close asChild>
<button className="px-4 py-2 rounded bg-gray-100">
Cancel
</button>
</Dialog.Close>
<button
className="px-4 py-2 rounded bg-blue-500 text-white"
onClick={() => {
// Action
setOpen(false)
}}
>
Confirm
</button>
</div>
</Modal>
</>
)
}Dialog with trigger
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Open
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description...</Dialog.Description>
<Dialog.Close asChild>
<button>Close</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>Dropdown Menu
A comprehensive dropdown menu with full keyboard support:
// components/DropdownMenu.tsx
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {
User,
Settings,
CreditCard,
LogOut,
ChevronRight,
Check
} from 'lucide-react'
interface UserMenuProps {
user: {
name: string
email: string
avatar: string
}
onLogout: () => void
}
export function UserMenu({ user, onLogout }: UserMenuProps) {
const [theme, setTheme] = useState('system')
const [notifications, setNotifications] = useState(true)
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center gap-2 p-2 rounded-full hover:bg-gray-100">
<img
src={user.avatar}
alt={user.name}
className="w-8 h-8 rounded-full"
/>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white dark:bg-gray-900
rounded-lg shadow-lg border border-gray-200 dark:border-gray-800
p-1.5 animate-in fade-in-0 zoom-in-95"
sideOffset={8}
align="end"
>
{/* Header with user information */}
<DropdownMenu.Label className="px-2 py-1.5">
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</DropdownMenu.Label>
<DropdownMenu.Separator className="h-px bg-gray-200 dark:bg-gray-800 my-1" />
{/* Regular menu items */}
<DropdownMenu.Item
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none
hover:bg-gray-100 dark:hover:bg-gray-800
focus:bg-gray-100 dark:focus:bg-gray-800"
>
<User className="w-4 h-4" />
<span>Profile</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100 focus:bg-gray-100">
<CreditCard className="w-4 h-4" />
<span>Billing</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100 focus:bg-gray-100">
<Settings className="w-4 h-4" />
<span>Settings</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
{/* Submenu */}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
className="flex items-center justify-between px-2 py-1.5 rounded
cursor-pointer outline-none
hover:bg-gray-100 focus:bg-gray-100
data-[state=open]:bg-gray-100"
>
<span>Theme</span>
<ChevronRight className="w-4 h-4" />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className="min-w-[160px] bg-white rounded-lg shadow-lg border p-1.5"
sideOffset={4}
>
<DropdownMenu.RadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenu.RadioItem
value="light"
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">Light</span>
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="dark"
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">Dark</span>
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="system"
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">System</span>
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
{/* Checkbox item */}
<DropdownMenu.CheckboxItem
checked={notifications}
onCheckedChange={setNotifications}
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer outline-none hover:bg-gray-100"
>
<DropdownMenu.ItemIndicator>
<Check className="w-4 h-4" />
</DropdownMenu.ItemIndicator>
<span className="pl-6">Notifications</span>
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
{/* Logout with custom styling */}
<DropdownMenu.Item
onClick={onLogout}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none
text-red-600 hover:bg-red-50 focus:bg-red-50"
>
<LogOut className="w-4 h-4" />
<span>Log out</span>
</DropdownMenu.Item>
{/* Arrow pointing to the trigger */}
<DropdownMenu.Arrow className="fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}Popover
Ideal for tooltips, inline editing, and floating panels:
// components/Popover.tsx
import * as Popover from '@radix-ui/react-popover'
import { Settings, X } from 'lucide-react'
export function SettingsPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Settings"
>
<Settings className="w-5 h-5" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="w-80 bg-white dark:bg-gray-900 rounded-lg shadow-xl
border border-gray-200 dark:border-gray-800 p-4
animate-in fade-in-0 zoom-in-95
data-[side=bottom]:slide-in-from-top-2
data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2
data-[side=top]:slide-in-from-bottom-2"
sideOffset={8}
>
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold">Display settings</h3>
<Popover.Close asChild>
<button className="p-1 rounded hover:bg-gray-100">
<X className="w-4 h-4" />
</button>
</Popover.Close>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
Text size
</label>
<select className="w-full px-3 py-2 border rounded-lg">
<option>Small</option>
<option>Medium</option>
<option>Large</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Contrast
</label>
<input
type="range"
min="0"
max="100"
className="w-full"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Dark mode</span>
<Switch />
</div>
</div>
<Popover.Arrow className="fill-white" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}Tooltip
Lightweight tooltips with full accessibility support:
// components/Tooltip.tsx
import * as Tooltip from '@radix-ui/react-tooltip'
interface TooltipWrapperProps {
content: string
children: React.ReactNode
side?: 'top' | 'right' | 'bottom' | 'left'
delayDuration?: number
}
export function TooltipWrapper({
content,
children,
side = 'top',
delayDuration = 200,
}: TooltipWrapperProps) {
return (
<Tooltip.Provider delayDuration={delayDuration}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{children}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={side}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded-lg
animate-in fade-in-0 zoom-in-95
data-[side=bottom]:slide-in-from-top-2
data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2
data-[side=top]:slide-in-from-bottom-2"
sideOffset={4}
>
{content}
<Tooltip.Arrow className="fill-gray-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Usage
function Example() {
return (
<TooltipWrapper content="Click to copy">
<button className="p-2 rounded hover:bg-gray-100">
<Copy className="w-5 h-5" />
</button>
</TooltipWrapper>
)
}Application-level Provider
// app/providers.tsx
import * as Tooltip from '@radix-ui/react-tooltip'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={200} skipDelayDuration={500}>
{children}
</Tooltip.Provider>
)
}Tabs
Tabs with full keyboard support (arrow keys, Home, End):
// components/Tabs.tsx
import * as Tabs from '@radix-ui/react-tabs'
interface Tab {
value: string
label: string
content: React.ReactNode
disabled?: boolean
}
interface TabsProps {
tabs: Tab[]
defaultValue?: string
}
export function TabsComponent({ tabs, defaultValue }: TabsProps) {
return (
<Tabs.Root
defaultValue={defaultValue || tabs[0]?.value}
className="w-full"
>
<Tabs.List
className="flex border-b border-gray-200"
aria-label="Manage account"
>
{tabs.map((tab) => (
<Tabs.Trigger
key={tab.value}
value={tab.value}
disabled={tab.disabled}
className="px-4 py-2 text-sm font-medium
border-b-2 border-transparent
text-gray-500 hover:text-gray-700
data-[state=active]:border-blue-500
data-[state=active]:text-blue-600
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{tab.label}
</Tabs.Trigger>
))}
</Tabs.List>
{tabs.map((tab) => (
<Tabs.Content
key={tab.value}
value={tab.value}
className="pt-4 focus:outline-none"
>
{tab.content}
</Tabs.Content>
))}
</Tabs.Root>
)
}
// Usage
function AccountSettings() {
const tabs = [
{
value: 'profile',
label: 'Profile',
content: <ProfileForm />,
},
{
value: 'security',
label: 'Security',
content: <SecuritySettings />,
},
{
value: 'notifications',
label: 'Notifications',
content: <NotificationSettings />,
},
{
value: 'billing',
label: 'Billing',
content: <BillingInfo />,
disabled: true, // Unavailable
},
]
return <TabsComponent tabs={tabs} defaultValue="profile" />
}Vertical Tabs
<Tabs.Root
defaultValue="tab1"
orientation="vertical"
className="flex gap-4"
>
<Tabs.List className="flex flex-col w-48 border-r">
<Tabs.Trigger value="tab1" className="px-4 py-2 text-left data-[state=active]:bg-gray-100">
Tab 1
</Tabs.Trigger>
<Tabs.Trigger value="tab2" className="px-4 py-2 text-left data-[state=active]:bg-gray-100">
Tab 2
</Tabs.Trigger>
</Tabs.List>
<div className="flex-1">
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</div>
</Tabs.Root>Accordion
Collapsible sections with animations:
// components/Accordion.tsx
import * as Accordion from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react'
interface AccordionItem {
value: string
trigger: string
content: React.ReactNode
}
interface AccordionProps {
items: AccordionItem[]
type?: 'single' | 'multiple'
defaultValue?: string | string[]
}
export function AccordionComponent({
items,
type = 'single',
defaultValue,
}: AccordionProps) {
return (
<Accordion.Root
type={type}
defaultValue={defaultValue}
collapsible={type === 'single'}
className="w-full"
>
{items.map((item) => (
<Accordion.Item
key={item.value}
value={item.value}
className="border-b border-gray-200"
>
<Accordion.Header>
<Accordion.Trigger
className="flex w-full items-center justify-between
py-4 font-medium text-left
hover:underline
[&[data-state=open]>svg]:rotate-180"
>
{item.trigger}
<ChevronDown
className="w-5 h-5 text-gray-500 transition-transform duration-200"
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
className="overflow-hidden
data-[state=closed]:animate-accordion-up
data-[state=open]:animate-accordion-down"
>
<div className="pb-4 text-gray-600">
{item.content}
</div>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
)
}
// Tailwind config for animations
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
}Select
A full select with search, groups, and keyboard navigation:
// components/Select.tsx
import * as Select from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
interface SelectOption {
value: string
label: string
disabled?: boolean
}
interface SelectGroup {
label: string
options: SelectOption[]
}
interface SelectProps {
placeholder?: string
value?: string
onValueChange: (value: string) => void
groups: SelectGroup[]
}
export function SelectComponent({
placeholder = 'Select...',
value,
onValueChange,
groups,
}: SelectProps) {
return (
<Select.Root value={value} onValueChange={onValueChange}>
<Select.Trigger
className="flex items-center justify-between w-full px-3 py-2
border border-gray-300 rounded-lg bg-white
focus:outline-none focus:ring-2 focus:ring-blue-500
data-[placeholder]:text-gray-400"
>
<Select.Value placeholder={placeholder} />
<Select.Icon>
<ChevronDown className="w-4 h-4 text-gray-500" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
className="overflow-hidden bg-white rounded-lg shadow-lg border
animate-in fade-in-0 zoom-in-95"
position="popper"
sideOffset={4}
>
<Select.ScrollUpButton className="flex items-center justify-center h-6 bg-white">
<ChevronUp className="w-4 h-4" />
</Select.ScrollUpButton>
<Select.Viewport className="p-1">
{groups.map((group, groupIndex) => (
<Select.Group key={group.label}>
{groupIndex > 0 && (
<Select.Separator className="h-px bg-gray-200 my-1" />
)}
<Select.Label className="px-2 py-1.5 text-xs font-medium text-gray-500">
{group.label}
</Select.Label>
{group.options.map((option) => (
<Select.Item
key={option.value}
value={option.value}
disabled={option.disabled}
className="relative flex items-center px-8 py-2 rounded
cursor-pointer outline-none
hover:bg-gray-100 focus:bg-gray-100
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
>
<Select.ItemIndicator className="absolute left-2">
<Check className="w-4 h-4" />
</Select.ItemIndicator>
<Select.ItemText>{option.label}</Select.ItemText>
</Select.Item>
))}
</Select.Group>
))}
</Select.Viewport>
<Select.ScrollDownButton className="flex items-center justify-center h-6 bg-white">
<ChevronDown className="w-4 h-4" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
// Usage
function CountrySelect() {
const [country, setCountry] = useState('')
const groups = [
{
label: 'Europe',
options: [
{ value: 'pl', label: 'Poland' },
{ value: 'de', label: 'Germany' },
{ value: 'fr', label: 'France' },
],
},
{
label: 'America',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
},
]
return (
<SelectComponent
placeholder="Select country"
value={country}
onValueChange={setCountry}
groups={groups}
/>
)
}Switch
Toggle switch with animation:
// components/Switch.tsx
import * as Switch from '@radix-ui/react-switch'
interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
label?: string
disabled?: boolean
}
export function SwitchComponent({
checked,
onCheckedChange,
label,
disabled = false,
}: SwitchProps) {
return (
<div className="flex items-center gap-3">
<Switch.Root
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className="w-11 h-6 bg-gray-200 rounded-full
relative cursor-pointer
data-[state=checked]:bg-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Switch.Thumb
className="block w-5 h-5 bg-white rounded-full shadow
transition-transform duration-200
translate-x-0.5 data-[state=checked]:translate-x-[22px]"
/>
</Switch.Root>
{label && (
<label className="text-sm font-medium">
{label}
</label>
)}
</div>
)
}
// Usage
function NotificationSettings() {
const [emailNotifications, setEmailNotifications] = useState(true)
const [pushNotifications, setPushNotifications] = useState(false)
return (
<div className="space-y-4">
<SwitchComponent
checked={emailNotifications}
onCheckedChange={setEmailNotifications}
label="Email notifications"
/>
<SwitchComponent
checked={pushNotifications}
onCheckedChange={setPushNotifications}
label="Push notifications"
/>
</div>
)
}Checkbox
Checkbox with indeterminate state:
// components/Checkbox.tsx
import * as Checkbox from '@radix-ui/react-checkbox'
import { Check, Minus } from 'lucide-react'
interface CheckboxProps {
checked: boolean | 'indeterminate'
onCheckedChange: (checked: boolean | 'indeterminate') => void
label: string
description?: string
}
export function CheckboxComponent({
checked,
onCheckedChange,
label,
description,
}: CheckboxProps) {
return (
<div className="flex items-start gap-3">
<Checkbox.Root
checked={checked}
onCheckedChange={onCheckedChange}
className="w-5 h-5 border-2 border-gray-300 rounded
flex items-center justify-center
data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500
data-[state=indeterminate]:bg-blue-500 data-[state=indeterminate]:border-blue-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Checkbox.Indicator>
{checked === 'indeterminate' ? (
<Minus className="w-3 h-3 text-white" />
) : (
<Check className="w-3 h-3 text-white" />
)}
</Checkbox.Indicator>
</Checkbox.Root>
<div>
<label className="text-sm font-medium cursor-pointer">
{label}
</label>
{description && (
<p className="text-sm text-gray-500">{description}</p>
)}
</div>
</div>
)
}
// List with "Select All"
function CheckboxList() {
const [items, setItems] = useState([
{ id: '1', label: 'Item 1', checked: false },
{ id: '2', label: 'Item 2', checked: true },
{ id: '3', label: 'Item 3', checked: false },
])
const allChecked = items.every(item => item.checked)
const someChecked = items.some(item => item.checked)
const selectAllState = allChecked ? true : someChecked ? 'indeterminate' : false
const handleSelectAll = (checked: boolean | 'indeterminate') => {
if (checked === 'indeterminate') return
setItems(items.map(item => ({ ...item, checked })))
}
const handleItemChange = (id: string, checked: boolean | 'indeterminate') => {
if (checked === 'indeterminate') return
setItems(items.map(item =>
item.id === id ? { ...item, checked } : item
))
}
return (
<div className="space-y-3">
<CheckboxComponent
checked={selectAllState}
onCheckedChange={handleSelectAll}
label="Select all"
/>
<div className="ml-6 space-y-2">
{items.map(item => (
<CheckboxComponent
key={item.id}
checked={item.checked}
onCheckedChange={(checked) => handleItemChange(item.id, checked)}
label={item.label}
/>
))}
</div>
</div>
)
}Slider
Slider with multiple values:
// components/Slider.tsx
import * as Slider from '@radix-ui/react-slider'
interface SliderProps {
value: number[]
onValueChange: (value: number[]) => void
min?: number
max?: number
step?: number
label?: string
}
export function SliderComponent({
value,
onValueChange,
min = 0,
max = 100,
step = 1,
label,
}: SliderProps) {
return (
<div className="w-full">
{label && (
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">{label}</span>
<span className="text-sm text-gray-500">{value.join(' - ')}</span>
</div>
)}
<Slider.Root
value={value}
onValueChange={onValueChange}
min={min}
max={max}
step={step}
className="relative flex items-center w-full h-5 select-none"
>
<Slider.Track className="relative h-2 w-full grow rounded-full bg-gray-200">
<Slider.Range className="absolute h-full rounded-full bg-blue-500" />
</Slider.Track>
{value.map((_, index) => (
<Slider.Thumb
key={index}
className="block w-5 h-5 bg-white border-2 border-blue-500 rounded-full
shadow focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
hover:bg-blue-50"
/>
))}
</Slider.Root>
</div>
)
}
// Usage - single slider
function VolumeControl() {
const [volume, setVolume] = useState([50])
return (
<SliderComponent
value={volume}
onValueChange={setVolume}
label="Volume"
/>
)
}
// Usage - range
function PriceRange() {
const [range, setRange] = useState([20, 80])
return (
<SliderComponent
value={range}
onValueChange={setRange}
min={0}
max={1000}
step={10}
label="Price range (USD)"
/>
)
}Navigation Menu
Advanced navigation menu (like on large websites):
// components/NavigationMenu.tsx
import * as NavigationMenu from '@radix-ui/react-navigation-menu'
import { ChevronDown } from 'lucide-react'
export function MainNavigation() {
return (
<NavigationMenu.Root className="relative z-10">
<NavigationMenu.List className="flex items-center gap-1 p-1">
{/* Link without submenu */}
<NavigationMenu.Item>
<NavigationMenu.Link
href="/"
className="px-3 py-2 rounded-md text-sm font-medium
hover:bg-gray-100 focus:outline-none focus:ring-2"
>
Home
</NavigationMenu.Link>
</NavigationMenu.Item>
{/* Products with mega menu */}
<NavigationMenu.Item>
<NavigationMenu.Trigger
className="flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium
hover:bg-gray-100 focus:outline-none focus:ring-2
data-[state=open]:bg-gray-100"
>
Products
<ChevronDown className="w-4 h-4 transition-transform data-[state=open]:rotate-180" />
</NavigationMenu.Trigger>
<NavigationMenu.Content
className="absolute top-full left-0 w-full
data-[motion=from-start]:animate-enterFromLeft
data-[motion=from-end]:animate-enterFromRight
data-[motion=to-start]:animate-exitToLeft
data-[motion=to-end]:animate-exitToRight"
>
<div className="grid grid-cols-3 gap-4 p-6 bg-white shadow-lg rounded-lg border">
<div>
<h3 className="font-semibold mb-3">For Developers</h3>
<ul className="space-y-2">
<li><a href="/api" className="text-sm hover:text-blue-500">API</a></li>
<li><a href="/sdk" className="text-sm hover:text-blue-500">SDK</a></li>
<li><a href="/docs" className="text-sm hover:text-blue-500">Documentation</a></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-3">For Business</h3>
<ul className="space-y-2">
<li><a href="/enterprise" className="text-sm hover:text-blue-500">Enterprise</a></li>
<li><a href="/pricing" className="text-sm hover:text-blue-500">Pricing</a></li>
<li><a href="/case-studies" className="text-sm hover:text-blue-500">Case Studies</a></li>
</ul>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold mb-2">New!</h3>
<p className="text-sm text-gray-600 mb-3">
Check out our latest product - AI Assistant.
</p>
<a
href="/ai-assistant"
className="text-sm text-blue-500 font-medium"
>
Learn more →
</a>
</div>
</div>
</NavigationMenu.Content>
</NavigationMenu.Item>
{/* Resources with list */}
<NavigationMenu.Item>
<NavigationMenu.Trigger
className="flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium
hover:bg-gray-100"
>
Resources
<ChevronDown className="w-4 h-4" />
</NavigationMenu.Trigger>
<NavigationMenu.Content className="absolute top-full left-0">
<ul className="w-48 p-2 bg-white shadow-lg rounded-lg border">
<li>
<NavigationMenu.Link
href="/blog"
className="block px-3 py-2 rounded hover:bg-gray-100 text-sm"
>
Blog
</NavigationMenu.Link>
</li>
<li>
<NavigationMenu.Link
href="/tutorials"
className="block px-3 py-2 rounded hover:bg-gray-100 text-sm"
>
Tutorials
</NavigationMenu.Link>
</li>
<li>
<NavigationMenu.Link
href="/changelog"
className="block px-3 py-2 rounded hover:bg-gray-100 text-sm"
>
Changelog
</NavigationMenu.Link>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Indicator
className="top-full z-10 flex h-2.5 items-end justify-center overflow-hidden
data-[state=visible]:animate-in data-[state=visible]:fade-in
data-[state=hidden]:animate-out data-[state=hidden]:fade-out"
>
<div className="relative top-1/2 h-2 w-2 rotate-45 bg-white border" />
</NavigationMenu.Indicator>
</NavigationMenu.List>
<NavigationMenu.Viewport
className="absolute top-full left-0 flex w-full justify-center"
/>
</NavigationMenu.Root>
)
}Context Menu
Context menu (right-click):
// components/ContextMenu.tsx
import * as ContextMenu from '@radix-ui/react-context-menu'
import { Copy, Trash, Edit, Share } from 'lucide-react'
interface FileContextMenuProps {
children: React.ReactNode
onCopy: () => void
onEdit: () => void
onDelete: () => void
onShare: () => void
}
export function FileContextMenu({
children,
onCopy,
onEdit,
onDelete,
onShare,
}: FileContextMenuProps) {
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
{children}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
className="min-w-[180px] bg-white rounded-lg shadow-lg border p-1
animate-in fade-in-0 zoom-in-95"
>
<ContextMenu.Item
onClick={onCopy}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none hover:bg-gray-100"
>
<Copy className="w-4 h-4" />
Copy
<ContextMenu.Shortcut className="ml-auto text-xs text-gray-400">
⌘C
</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Item
onClick={onEdit}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none hover:bg-gray-100"
>
<Edit className="w-4 h-4" />
Edit
<ContextMenu.Shortcut className="ml-auto text-xs text-gray-400">
⌘E
</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Item
onClick={onShare}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none hover:bg-gray-100"
>
<Share className="w-4 h-4" />
Share
</ContextMenu.Item>
<ContextMenu.Separator className="h-px bg-gray-200 my-1" />
<ContextMenu.Item
onClick={onDelete}
className="flex items-center gap-2 px-2 py-1.5 rounded
cursor-pointer outline-none text-red-600 hover:bg-red-50"
>
<Trash className="w-4 h-4" />
Delete
<ContextMenu.Shortcut className="ml-auto text-xs">
⌫
</ContextMenu.Shortcut>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
)
}
// Usage
function FileItem({ file }: { file: File }) {
return (
<FileContextMenu
onCopy={() => console.log('Copy', file.name)}
onEdit={() => console.log('Edit', file.name)}
onDelete={() => console.log('Delete', file.name)}
onShare={() => console.log('Share', file.name)}
>
<div className="p-4 border rounded hover:bg-gray-50 cursor-pointer">
<span>{file.name}</span>
</div>
</FileContextMenu>
)
}Alert Dialog
A confirmation dialog for destructive actions:
// components/AlertDialog.tsx
import * as AlertDialog from '@radix-ui/react-alert-dialog'
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
variant?: 'danger' | 'warning'
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
variant = 'danger',
}: ConfirmDialogProps) {
const confirmButtonClass = variant === 'danger'
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-yellow-500 hover:bg-yellow-600 text-white'
return (
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay
className="fixed inset-0 bg-black/50 backdrop-blur-sm
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-full max-w-md bg-white rounded-xl shadow-xl p-6
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>
<AlertDialog.Title className="text-xl font-semibold">
{title}
</AlertDialog.Title>
<AlertDialog.Description className="mt-2 text-gray-500">
{description}
</AlertDialog.Description>
<div className="flex justify-end gap-3 mt-6">
<AlertDialog.Cancel asChild>
<button className="px-4 py-2 rounded-lg border hover:bg-gray-50">
{cancelLabel}
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-lg ${confirmButtonClass}`}
>
{confirmLabel}
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
// Usage
function DeleteButton() {
const [open, setOpen] = useState(false)
const handleDelete = () => {
// Perform deletion
console.log('Deleted!')
setOpen(false)
}
return (
<>
<button
onClick={() => setOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded"
>
Delete account
</button>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Are you sure you want to delete your account?"
description="This action is irreversible. All your data will be permanently deleted."
confirmLabel="Delete account"
onConfirm={handleDelete}
variant="danger"
/>
</>
)
}Animations with Framer Motion
Radix works great with Framer Motion:
import * as Dialog from '@radix-ui/react-dialog'
import { motion, AnimatePresence } from 'framer-motion'
import { forwardRef } from 'react'
// Motion wrapper for Radix
const MotionOverlay = motion(
forwardRef<HTMLDivElement, Dialog.DialogOverlayProps>((props, ref) => (
<Dialog.Overlay ref={ref} {...props} />
))
)
const MotionContent = motion(
forwardRef<HTMLDivElement, Dialog.DialogContentProps>((props, ref) => (
<Dialog.Content ref={ref} {...props} />
))
)
interface AnimatedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
export function AnimatedDialog({ open, onOpenChange, children }: AnimatedDialogProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<MotionOverlay
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
<MotionContent
className="fixed left-1/2 top-1/2 w-full max-w-lg bg-white rounded-xl p-6"
initial={{
opacity: 0,
scale: 0.95,
x: '-50%',
y: '-50%',
}}
animate={{
opacity: 1,
scale: 1,
x: '-50%',
y: '-50%',
}}
exit={{
opacity: 0,
scale: 0.95,
x: '-50%',
y: '-50%',
}}
transition={{
type: 'spring',
damping: 25,
stiffness: 300
}}
>
{children}
</MotionContent>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
)
}Complete list of Radix primitives
| Component | Description | Usage |
|---|---|---|
| Dialog | Modal/popup | Forms, confirmations |
| Alert Dialog | Confirmation dialog | Destructive actions |
| Dropdown Menu | Dropdown menu | User actions |
| Context Menu | Context menu | Right-click |
| Menubar | Menu bar | Desktop-like applications |
| Navigation Menu | Navigation menu | Main navigation |
| Popover | Floating panel | Inline editing, settings |
| Hover Card | Card on hover | User preview |
| Tooltip | Tooltip | UI explanations |
| Accordion | Collapsible sections | FAQ, lists |
| Collapsible | Single section | Show more/less |
| Tabs | Tabs | In-view navigation |
| Toggle | Toggle button | On/off actions |
| Toggle Group | Toggle group | Radio buttons |
| Select | Dropdown select | Forms |
| Slider | Slider | Value ranges |
| Switch | Toggle switch | Boolean settings |
| Checkbox | Checkbox | Multi-select |
| Radio Group | Radio buttons | Single select |
| Avatar | Avatar | User photo |
| Progress | Progress bar | Loading, progress |
| Scroll Area | Scroll area | Custom scrollbars |
| Separator | Separator | Visual separation |
| Toolbar | Toolbar | Editor actions |
| Toast | Notifications | User feedback |
| Aspect Ratio | Aspect ratio | Media containers |
| Form | Form | Form validation |
| Label | Label | Accessibility labels |
| Visually Hidden | Hidden text | Screen reader only |
Summary
Radix UI is the foundation of modern React component libraries. Key advantages:
- Full accessibility - WAI-ARIA compliant, keyboard navigation
- Unstyled - 100% control over appearance
- Composable API - Flexible UI building
- Small sizes - Import only the components you need
- SSR ready - Works with Next.js out of the box
- Animations - Full support for Framer Motion and CSS
If you are building your own UI system or using Tailwind CSS, Radix UI is the ideal choice as the foundation for your components.