Button
액션을 실행하는 컨트롤입니다. Base UI의 Button 프리미티브 위에 Neba의 아크릴 표면을 올렸습니다.
tsx
import { Button } from 'neba';
<Button onClick={save}>저장</Button>;Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | 'solid' | 'outline' | 'text' | 'solid' | 표면의 무게. 채움 / 하이라인 / 없음 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 높이와 타입 스케일. xs 22px · sm 26px · md 32px · lg 40px · xl 48px |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 0 | 그림자 깊이. 0은 그림자 없음. 호버는 한 단계 올리고, 누르면 한 단계 내립니다 |
| startIcon | ReactNode | — | 라벨 앞에 놓이는 내용. 1.2em으로 그려져 라벨 크기를 따라갑니다 |
| endIcon | ReactNode | — | 라벨 뒤에 놓이는 내용 |
| loading | boolean | false | startIcon 자리에 스피너를 띄우고 활성화를 막습니다. 포커스는 유지됩니다 |
| readOnly | boolean | false | 비활성이되 흐려지지 않음. 액션은 존재하지만 여기서는 쓸 수 없다는 뜻 |
| disabled | boolean | false | 사용 불가. 색 계열을 버리고 중립 회색이 되며, 포커스 순서에서 빠집니다 |
| fullWidth | boolean | false | 컨테이너 너비만큼 확장 |
| children | ReactNode | — | 라벨. 생략하면 정사각형 아이콘 버튼이 됩니다 |
<button>의 네이티브 속성은 그대로 전달됩니다. color만 예외로, 위 표의 color와 이름이 겹쳐 제외되어 있습니다.
공통 축(variant size color density elevation)이 컴포넌트 전반에서 뜻하는 바는 Prop 규약에 있습니다.
예시
변형
한 화면에 solid는 하나만 두세요. 주 액션이 둘이면 어느 쪽도 주 액션이 아닙니다.
tsx
import { Button } from 'neba';
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="solid">Save</Button>
<Button variant="outline">Cancel</Button>
<Button variant="text">Details</Button>
</div>
);
}색
여섯 가지 역할이 전부입니다. 임의의 색상값은 받지 않습니다 — 색은 값이 아니라 역할입니다.
tsx
import { Button } from 'neba';
const COLORS = ['primary', 'secondary', 'success', 'warning', 'danger', 'info'] as const;
export default function ButtonColors() {
return (
<div className="flex flex-col gap-3">
{(['solid', 'outline', 'text'] as const).map((variant) => (
<div key={variant} className="flex flex-wrap items-center gap-2">
{COLORS.map((color) => (
<Button key={color} variant={variant} color={color}>
{color}
</Button>
))}
</div>
))}
</div>
);
}크기
md(32px)가 데스크톱 기본입니다. xs sm은 툴바와 표 안쪽에, lg xl은 그 화면이 실제로 요구하는 하나의 액션에 씁니다.
tsx
import { Button } from 'neba';
export default function ButtonSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button size="xs">xs</Button>
<Button size="sm">sm</Button>
<Button size="md">md</Button>
<Button size="lg">lg</Button>
<Button size="xl">xl</Button>
</div>
);
}밀도
density는 좌우 여백만 바꿉니다. 같은 size라면 밀도가 달라도 높이가 같아서, 섞어 놓아도 기준선이 흐트러지지 않습니다.
tsx
import { Button } from 'neba';
export default function ButtonDensity() {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button density="default">Save changes</Button>
<Button density="default" variant="outline">
Save changes
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button density="compact">Save changes</Button>
<Button density="compact" variant="outline">
Save changes
</Button>
</div>
</div>
);
}아이콘
아이콘은 1.2em으로 그려져 라벨 크기를 따라갑니다. 크기를 따로 줄 필요가 없습니다. 라벨 없이 아이콘만 넘기면 정사각형이 되며, 이때는 aria-label이 필요합니다.
tsx
import { Button } from 'neba';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
function ChevronIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m6 4 4 4-4 4"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function ButtonIcons() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button startIcon={<PlusIcon />}>New project</Button>
<Button variant="outline" endIcon={<ChevronIcon />}>
Continue
</Button>
<Button variant="outline" aria-label="Add" startIcon={<PlusIcon />} />
</div>
);
}상태
tsx
import { Button } from 'neba';
export default function ButtonStates() {
return (
<div className="flex flex-col gap-3">
{(['solid', 'outline', 'text'] as const).map((variant) => (
<div key={variant} className="flex flex-wrap items-center gap-2">
<Button variant={variant}>Normal</Button>
<Button variant={variant} loading>
Loading
</Button>
<Button variant={variant} disabled>
Disabled
</Button>
<Button variant={variant} readOnly>
Read-only
</Button>
</div>
))}
</div>
);
}| 상태 | 겉모습 | 포커스 | 네이티브 disabled |
|---|---|---|---|
loading | 그대로. 스피너가 startIcon 자리를 대신 | 유지 | 아니오 |
readOnly | 색은 유지, 평평해지고 채도가 빠짐 | 유지 | 아니오 |
disabled | 색 계열을 버리고 중립 회색 | 빠짐 | 예 |
세 상태 모두 클릭이 부모로 전파되지 않습니다.
Elevation
기본값 0은 그림자가 전혀 없다는 뜻입니다. 표면을 배경에서 떼어 놓는 것은 아크릴 가장자리입니다. 호버하면 한 단계 오르고 누르면 한 단계 내려가므로, 평평한 버튼도 움직이지 않고 눌린 것을 표현합니다.
tsx
import { Button } from 'neba';
export default function ButtonElevation() {
return (
<div className="flex flex-wrap items-center gap-4">
<Button elevation={0} size="lg">
elevation 0
</Button>
<Button elevation={1} size="lg">
elevation 1
</Button>
<Button elevation={2} size="lg">
elevation 2
</Button>
<Button elevation={3} size="lg">
elevation 3
</Button>
</div>
);
}전체 너비
tsx
import { Button } from 'neba';
export default function ButtonFullWidth() {
return (
<div className="flex max-w-sm flex-col gap-2">
<Button fullWidth size="lg">
Create workspace
</Button>
<Button fullWidth variant="text" color="secondary">
Maybe later
</Button>
</div>
);
}접근성
- 항상 네이티브
<button>으로 렌더링됩니다.type도 그대로 전달되므로 폼 안에서type="submit"이 동작합니다. - 아이콘 전용 버튼에는
aria-label을 주세요. - 포커스 링은
:focus-visible에서만 나타납니다. 마우스 클릭에는 보이지 않습니다. loading과readOnly는 포커스를 유지합니다. 포커스 순서에서 사라지면 키보드 사용자가 페이지 구조를 잃기 때문입니다.- 모든 색 조합이 채움 위 글자 4.5:1을 만족합니다.