๐ก ํ์ฌ์์ ๋์์ธ ์์คํ ์ ๊ตฌ์ถํ๋ ์ ๋ฌด๋ฅผ ๋งก๊ฒ ๋์๋ค. ๊ฑฐ๊ธฐ์ ์ฌ์ฉํ๋ ์คํ์์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ค ํ๋๊ฐ radix-ui์ด๋ค. ๊ทธ ์ ๋ช ํ shadcn/ui๊ฐ ์ด radix-ui๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ง๋ค์ด์ก๋ค๊ณ ํ๋ค. radix-ui์ ๋ํ ๊ธฐ๋ณธ์ ์ธ ์ดํด๋ฅผ ๊ฑฐ์น๋ฉด ์ ๋ฌด ์ํ์ด ์ข ๋ ์์ํ ๊ฒ ๊ฐ๊ธฐ๋ ํ๊ณ , ๋ฐ๋ณด์ ํ๋ก์ ํธ์์๋ ํ์ฉํด ๋ณผ ๋งํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ธ ๊ฒ ๊ฐ์์ ๊ณต๋ถ๋ฅผ ํด ๋ณด๋ฉด ์ข์ ๊ฒ ๊ฐ๋ค. ์์ด ๋ฌธ์๋ฅผ ๋์ผ๋ก ๋ฒ์ญํ๋ ๊ฑฐ๋ผ์ ์ค์ญ์ด ๋ง์ ์๋... ๋ฏธ๋ฆฌ ์ฃ์กํฉ๋๋ค๐
radix-ui ๊ณต์ ํํ์ด์ง
radix-ui ๊ณต์ github
๐ ๊ณ ํ์ง, ์ ๊ทผ ๊ฐ๋ฅํ ๋์์ธ ์์คํ ๊ณผ ์น์ฑ์ ๊ตฌ์ถํ๋ ๋ฐ ์ฌ์ฉํ๋ ์คํ์์ค UI ์ปดํฌ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
Radix Primitives๋ ์ ๊ทผ ๊ฐ๋ฅ์ฑ, ์ปค์คํ , DX์ ์ง์คํ low-level UI ์ปดํฌ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
๊ทธ๋งํผ ๊ฐ๋ฐ์๊ฐ ์ปค์คํ ํ๊ธฐ ์ฌ์ด headless UI ๊ฐ์ ๋๋์ ๋๋ค
๋ ๋ณด์ ์ธ UI/UX๋ฅผ ๊ฐ์ง ํ๋ค์ด ์ฌ์ฉํ๊ธฐ์ ์ข์ ๊ฒ ๊ฐ์!
์ฌ์ฉ์(๊ฐ๋ฐ์) ๊ฐ์ธ์ ๋์์ธ ์์คํ
์ base layer๋ก์ ์ฌ์ฉํ๊ธฐ์๋ ์ข๊ณ ,
์ ์ง์ ์ผ๋ก ์ ์ฉํด๋๊ฐ๊ธฐ๋ ์ข๋ค!
โ radix-ui์ ๋ชฉํ๋ ๊ฐ๋ฐ์ ์ปค๋ฎค๋ํฐ๊ฐ ์ฝ๊ฒ ๋์์ธ ์์คํ ์ ๊ตฌ์ถํ ์ ์๋๋ก ๋๋ ์คํ์์ค ์ปดํฌ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๋ง๋๋ ๊ฒ!
1. Accessible
2. Unstyled
3. Opened
4. Uncontrolled
5. Developer experience
6. Incremental Adoption
๊ฐ Primitives๋ ๊ฐ๋ณ์ ์ผ๋ก ์ค์น ๊ฐ๋ฅ
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip
๋ฒ์ ๋๋ ๋ ๋ฆฝ์ ์ผ๋ก ๋๋ค๊ณ ํจ (๋ฏธ์ณค๋ค)
npm install @radix-ui/{ํน์ radix primitive}@latest -E
์ฐธ๊ณ ๋ก -E
๋ --save-exact
๋ก ๋ฒ์ ๋ช
๊ณผ ์ ํํ ์ผ์นํ๋ ํจํค์ง๋ฅผ ๋ฐ์
// index.jsx
import * as React from 'react';
import * as Popover from '@radix-ui/react-popover';
const PopoverDemo = () => (
<Popover.Root>
<Popover.Trigger>More info</Popover.Trigger>
<Popover.Portal>
<Popover.Content>
Some more infoโฆ
<Popover.Arrow />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
export default PopoverDemo;
๊ฐ์ ธ์จ primitive๊ฐ Popover
๋ผ๊ณ ํ์ ๋, ์ด๋ฐ ์์ผ๋ก ์ฌ์ฉ ๊ฐ๋ฅ
// index.jsx
import * as React from 'react';
import * as Popover from '@radix-ui/react-popover';
import './styles.css';
const PopoverDemo = () => (
<Popover.Root>
<Popover.Trigger className="PopoverTrigger">Show info</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="PopoverContent">
Some content
<Popover.Arrow className="PopoverArrow" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
export default PopoverDemo;
/* styles.css */
.PopoverTrigger {
background-color: white;
border-radius: 4px;
}
.PopoverContent {
border-radius: 4px;
padding: 20px;
width: 260px;
background-color: white;
}
.PopoverArrow {
fill: white;
}
์คํ์ผ์ ์ถ๊ฐํ๋ ค๋ฉด ์ด๋ฐ ์์ผ๋ก ์ฌ์ฉํ ์ ์๋ค
.AccordionItem {
border-bottom: 1px solid gainsboro;
}
.AccordionItem[data-state='open'] {
border-bottom-width: 2px;
}
์ํ์ ๋ฐ๋ผ ์คํ์ผ์ด ๋ฌ๋ผ์ง ์ ์๋ ๋ถ๋ถ๋ค์ ์ด๋ฏธ radix ๋จ์์ data-state๊ฐ ์ง์ ๋์ด ์๋ค
๋ฐ๋ผ์ data-state์ ๋ง๊ฒ ์คํ์ผ์ ์ ์ฉํด ์ค ์ ์๋ค!
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.DialogOverlay[data-state='open'],
.DialogContent[data-state='open'] {
animation: fadeIn 300ms ease-out;
}
.DialogOverlay[data-state='closed'],
.DialogContent[data-state='closed'] {
animation: fadeOut 300ms ease-in;
}
์ ๋๋ฉ์ด์ ๋ ์์ ๊ฐ์ด ์์ธก ๊ฐ๋ฅํ ํํ๋ก ์ค ์ ์๋ค
Radix์ ๊ธฐ๋ฅ๊ณผ ์ฌ์ฉ์์ ์ปดํฌ๋ํธ๋ฅผ compose ํ๋ ค๋ฉด, asChild
prop์ ์ฌ์ฉํ์!!
Element type ๋ณ๊ฒฝ
Tooltip.Trigger
๊ฐ ์ข์ ์์ button
์ ๋ถ์ด์ hover ํ์ ๋ Tooltip์ด ๋ํ๋๊ฒ ๋์ด ์๋๋ฐ, a
๋งํฌ์ ๋ํ๋๊ฒ ์์ ํ ์ ์๋ค import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
export default () => (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a href="https://www.radix-ui.com/">Radix UI</a>
</Tooltip.Trigger>
<Tooltip.Portal>โฆ</Tooltip.Portal>
</Tooltip.Root>
);
asChild
๋ ๋ฌธ์ ๊ทธ๋๋ก child๋ก์ ์ฐ์ธ๋ค๋ ์๋ฏธ์ธ๋ฏ ๋ด๊ฐ ๋ง๋ React ์ปดํฌ๋ํธ์ Composing
asChild
์ ์๋ฆฌ๋ ๊ฑฐ์ ์ ์ฌํจ 1. props
// before
const MyButton = () => <button />;
// after
const MyButton = (props) => <button {...props} />;
2. forwardRef
ref
๋ฅผ ๋ถ์ฌ์ผ ํ๋ ๊ฒฝ์ฐ๊ฐ ์์ React.forwardRef
๋ฅผ ํตํด ์ํ๋จ // before
const MyButton = (props) => <button {...props} />;
// after
const MyButton = React.forwardRef((props, forwardedRef) => (
<button {...props} ref={forwardedRef} />
));
๋ชจ๋ ๋ถ๋ถ์ ํ์ํ ๊ฑด ์๋์ง๋ง, ๊ทธ๋ฅ ์ต๊ด์ฒ๋ผ ํญ์ ํ๋ ๊ฑธ ๊ถ์ฅํ๋ค๊ณ ํจ
Radix primitives๋ผ๋ฆฌ Composing
asChild
๋ depth๋ฅผ ์ค์ฒฉํด์ ๊น๊ฒ ์ฌ์ฉํ ์๋ ์์ Tooltip.Trigger
์ Dialog.Trigger
๋ฅผ ๊ฐ์ด ์ฌ์ฉํ๋ ์์ import * as React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
const MyButton = React.forwardRef((props, forwardedRef) => (
<button {...props} ref={forwardedRef} />
));
export default () => {
return (
<Dialog.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>Open dialog</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
<Tooltip.Portal>โฆ</Tooltip.Portal>
</Tooltip.Root>
<Dialog.Portal>...</Dialog.Portal>
</Dialog.Root>
);
};
Trigger
๊ฐ ๋์ค๊ณ , ๋๋ ์ ๋๋ Dialog
๊ฐ ๋์ฌ ์ ์๊ฒ, Button
ํ๋์ Trigger
, Dialog
๊ฐ ๊ฑธ๋ฆฌ๊ฒ ํ ์ ์๋ ๋ฏ! Radix primitive๋ SSR์ด๋ Static rendering์์๋ ์ฌ์ฉ ๊ฐ๋ฅ!
ํ์ฌ ํ๊ณ ์๋ ํ๋ก์ ํธ์์ ์ธ ์ ์์ ๋งํ primitive๋ฅผ ๋ช ๊ฐ์ง ์ ๋ฆฌํด ๋ณด์! tailwind css๋ฅผ ์ฌ์ฉํ๊ฒ ๋ ๊ฒ ๊ฐ๊ธด ํ์ง๋ง, ๋ธ๋ก๊ทธ ํฌ์คํ ์ฉ ์ ๋ฆฌ์ด๊ธฐ ๋๋ฌธ์ ๊ฐ๋ ์ฑ์ CSS๋ฅผ ์ด์ฉํ ์ฝ๋๋ก ๊ฐ์ ธ์ ๋ณด์๋ค.
import React from 'react';
import * as Accordion from '@radix-ui/react-accordion';
import classNames from 'classnames';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import './styles.css';
const AccordionDemo = () => (
<Accordion.Root className="AccordionRoot" type="single" defaultValue="item-1" collapsible>
<Accordion.Item className="AccordionItem" value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
</Accordion.Item>
<Accordion.Item className="AccordionItem" value="item-2">
<AccordionTrigger>Is it unstyled?</AccordionTrigger>
<AccordionContent>
Yes. It's unstyled by default, giving you freedom over the look and feel.
</AccordionContent>
</Accordion.Item>
<Accordion.Item className="AccordionItem" value="item-3">
<AccordionTrigger>Can it be animated?</AccordionTrigger>
<Accordion.Content className="AccordionContent">
<div className="AccordionContentText">
Yes! You can animate the Accordion with CSS or JavaScript.
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
const AccordionTrigger = React.forwardRef(({ children, className, ...props }, forwardedRef) => (
<Accordion.Header className="AccordionHeader">
<Accordion.Trigger
className={classNames('AccordionTrigger', className)}
{...props}
ref={forwardedRef}
>
{children}
<ChevronDownIcon className="AccordionChevron" aria-hidden />
</Accordion.Trigger>
</Accordion.Header>
));
const AccordionContent = React.forwardRef(({ children, className, ...props }, forwardedRef) => (
<Accordion.Content
className={classNames('AccordionContent', className)}
{...props}
ref={forwardedRef}
>
<div className="AccordionContentText">{children}</div>
</Accordion.Content>
));
export default AccordionDemo;
import React from 'react';
import * as Checkbox from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
import './styles.css';
const CheckboxDemo = () => (
<form>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Checkbox.Root className="CheckboxRoot" defaultChecked id="c1">
<Checkbox.Indicator className="CheckboxIndicator">
<CheckIcon />
</Checkbox.Indicator>
</Checkbox.Root>
<label className="Label" htmlFor="c1">
Accept terms and conditions.
</label>
</div>
</form>
);
export default CheckboxDemo;
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import './styles.css';
const DialogDemo = () => (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="Button violet">Edit profile</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="DialogOverlay" />
<Dialog.Content className="DialogContent">
<Dialog.Title className="DialogTitle">Edit profile</Dialog.Title>
<Dialog.Description className="DialogDescription">
Make changes to your profile here. Click save when you're done.
</Dialog.Description>
<fieldset className="Fieldset">
<label className="Label" htmlFor="name">
Name
</label>
<input className="Input" id="name" defaultValue="Pedro Duarte" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="username">
Username
</label>
<input className="Input" id="username" defaultValue="@peduarte" />
</fieldset>
<div style={{ display: 'flex', marginTop: 25, justifyContent: 'flex-end' }}>
<Dialog.Close asChild>
<button className="Button green">Save changes</button>
</Dialog.Close>
</div>
<Dialog.Close asChild>
<button className="IconButton" aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
export default DialogDemo;
Built-in validation๊ณผ custom validation ๋ชจ๋ ์ ๊ณตํ๋ ์ผ๋ฌด์ง ๋ ์์ด๋ค...
import React from 'react';
import * as Form from '@radix-ui/react-form';
import './styles.css';
const FormDemo = () => (
<Form.Root className="FormRoot">
<Form.Field className="FormField" name="email">
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<Form.Label className="FormLabel">Email</Form.Label>
<Form.Message className="FormMessage" match="valueMissing">
Please enter your email
</Form.Message>
<Form.Message className="FormMessage" match="typeMismatch">
Please provide a valid email
</Form.Message>
</div>
<Form.Control asChild>
<input className="Input" type="email" required />
</Form.Control>
</Form.Field>
<Form.Field className="FormField" name="question">
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<Form.Label className="FormLabel">Question</Form.Label>
<Form.Message className="FormMessage" match="valueMissing">
Please enter a question
</Form.Message>
</div>
<Form.Control asChild>
<textarea className="Textarea" required />
</Form.Control>
</Form.Field>
<Form.Submit asChild>
<button className="Button" style={{ marginTop: 10 }}>
Post question
</button>
</Form.Submit>
</Form.Root>
);
export default FormDemo;
import React from 'react';
import * as Popover from '@radix-ui/react-popover';
import { MixerHorizontalIcon, Cross2Icon } from '@radix-ui/react-icons';
import './styles.css';
const PopoverDemo = () => (
<Popover.Root>
<Popover.Trigger asChild>
<button className="IconButton" aria-label="Update dimensions">
<MixerHorizontalIcon />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="PopoverContent" sideOffset={5}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p className="Text" style={{ marginBottom: 10 }}>
Dimensions
</p>
<fieldset className="Fieldset">
<label className="Label" htmlFor="width">
Width
</label>
<input className="Input" id="width" defaultValue="100%" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="maxWidth">
Max. width
</label>
<input className="Input" id="maxWidth" defaultValue="300px" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="height">
Height
</label>
<input className="Input" id="height" defaultValue="25px" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="maxHeight">
Max. height
</label>
<input className="Input" id="maxHeight" defaultValue="none" />
</fieldset>
</div>
<Popover.Close className="PopoverClose" aria-label="Close">
<Cross2Icon />
</Popover.Close>
<Popover.Arrow className="PopoverArrow" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
export default PopoverDemo;
import React from 'react';
import * as RadioGroup from '@radix-ui/react-radio-group';
import './styles.css';
const RadioGroupDemo = () => (
<form>
<RadioGroup.Root className="RadioGroupRoot" defaultValue="default" aria-label="View density">
<div style={{ display: 'flex', alignItems: 'center' }}>
<RadioGroup.Item className="RadioGroupItem" value="default" id="r1">
<RadioGroup.Indicator className="RadioGroupIndicator" />
</RadioGroup.Item>
<label className="Label" htmlFor="r1">
Default
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<RadioGroup.Item className="RadioGroupItem" value="comfortable" id="r2">
<RadioGroup.Indicator className="RadioGroupIndicator" />
</RadioGroup.Item>
<label className="Label" htmlFor="r2">
Comfortable
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<RadioGroup.Item className="RadioGroupItem" value="compact" id="r3">
<RadioGroup.Indicator className="RadioGroupIndicator" />
</RadioGroup.Item>
<label className="Label" htmlFor="r3">
Compact
</label>
</div>
</RadioGroup.Root>
</form>
);
export default RadioGroupDemo;
import React from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import './styles.css';
const TabsDemo = () => (
<Tabs.Root className="TabsRoot" defaultValue="tab1">
<Tabs.List className="TabsList" aria-label="Manage your account">
<Tabs.Trigger className="TabsTrigger" value="tab1">
Account
</Tabs.Trigger>
<Tabs.Trigger className="TabsTrigger" value="tab2">
Password
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="TabsContent" value="tab1">
<p className="Text">Make changes to your account here. Click save when you're done.</p>
<fieldset className="Fieldset">
<label className="Label" htmlFor="name">
Name
</label>
<input className="Input" id="name" defaultValue="Pedro Duarte" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="username">
Username
</label>
<input className="Input" id="username" defaultValue="@peduarte" />
</fieldset>
<div style={{ display: 'flex', marginTop: 20, justifyContent: 'flex-end' }}>
<button className="Button green">Save changes</button>
</div>
</Tabs.Content>
<Tabs.Content className="TabsContent" value="tab2">
<p className="Text">Change your password here. After saving, you'll be logged out.</p>
<fieldset className="Fieldset">
<label className="Label" htmlFor="currentPassword">
Current password
</label>
<input className="Input" id="currentPassword" type="password" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="newPassword">
New password
</label>
<input className="Input" id="newPassword" type="password" />
</fieldset>
<fieldset className="Fieldset">
<label className="Label" htmlFor="confirmPassword">
Confirm password
</label>
<input className="Input" id="confirmPassword" type="password" />
</fieldset>
<div style={{ display: 'flex', marginTop: 20, justifyContent: 'flex-end' }}>
<button className="Button green">Change password</button>
</div>
</Tabs.Content>
</Tabs.Root>
);
export default TabsDemo;
๋ณด๋ฉด ๋ณผ์๋ก ์ผ๋ฌด์ง ๋ ์์ธ ๊ฒ ๊ฐ๋ค. ๊ธฐ๋ฐ์ด ์ ๋ง ๋จ๋จํ๊ณ ์ฌ์ฉํ๊ธฐ์๋ ์ง๊ด์ ์ด๋ผ๋ ๋๋์ ๋ฐ์๋ค. ์์ ์ MUI ํ ๋ฒ ์๋ชป ์ผ๋ค๊ฐ ๋ฒ์ ์ด์๋ก ํธ๋๊ฒ ํผ๋๊ณ ์ดํ๋ก UI ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ์ ๋ฌํ ๊ฑฐ๋ถ๊ฐ์ด ์์๋๋ฐ, ์์ฆ์ ์ด๋ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ ์ฐ๋ ๊ฒ๋ ํ๋์ ์คํฌ์ด๋ผ๋ ์๊ฐ์ด ๋ค์๋ค. ์ ๋ฌด ๋ณด๋ฉด์ ์ ์ ๋ ์ต์ํด์ ธ์ผ๊ฒ ๋ค!
l