FloatingActionButton
화면이 다루는 단 하나의 행동을, 그 화면 위에 띄워 놓은 둥근 버튼입니다. FAB이라고도 부릅니다. 자식으로 FloatingAction을 넣으면 눌렀을 때 펼쳐지는 작은 액션 묶음이 됩니다.
import { FloatingActionButton } from 'neba';
<FloatingActionButton icon={<PencilIcon />} label="새 글" onClick={compose} />;Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | 'solid' | 'outline' | 'text' | 'solid' | 표면의 무게. 채움 / 하이라인 / 없음 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'lg' | 높이. 사다리는 Button과 같고 시작점만 한 칸 위입니다. 보지 않고 엄지로 찾아 누르는 유일한 컨트롤이기 때문입니다 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 2 | 그림자 깊이. Pill과 같은 이유로 기본이 2입니다. 이 버튼은 페이지의 일부가 아니라 그 위에 떠 있습니다 |
| icon | ReactNode | a plus | 버튼의 글리프 |
| label * | string | — | 버튼이 무엇을 하는지, 말로. 필수입니다. 그림만으로 된 버튼은 접근성 이름이 아예 없습니다. extended일 때는 버튼에 쓰이는 말이기도 합니다 |
| extended | boolean | false | label을 글리프 옆에 씁니다. 원이 스타디움이 됩니다 |
| openIcon | ReactNode | — | 다이얼이 열려 있는 동안의 글리프. 액션이 있으면 기본이 ×입니다. icon과 같은 노드를 넘기면 바뀌지 않습니다 |
| position | 'static' | 'sticky' | 'fixed' | 'absolute' | 'fixed' | 어디에 앉는지. fixed는 창의 모서리에, absolute는 가장 가까운 positioned 조상의 모서리에 고정합니다 |
| corner공통 | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'bottom-end' | 어느 모서리에 붙는지 |
| offset | number | string | 16 | 양쪽 가장자리에서 얼마나 안쪽인지. CSS 길이 또는 픽셀 수 |
| direction | 'top' | 'bottom' | — | 액션이 펼쳐지는 방향. 지정하지 않으면 corner에서 가져옵니다 |
| open | boolean | — | 다이얼이 열려 있는지. 직접 제어할 때 |
| defaultOpen | boolean | false | 제어하지 않을 때의 시작 상태 |
| onOpenChange | (open: boolean) => void | — | 다이얼이 열리거나 닫혔을 때 |
| openOnHover | boolean | true | 마우스가 버튼에 머무르면 다이얼을 엽니다. 터치와 펜은 제외됩니다 |
| closeOnAction | boolean | true | 액션을 누르면 다이얼을 닫습니다 |
| showLabels | boolean | true | 각 액션의 이름을 옆의 로젠지에 그립니다. 꺼도 이름은 그대로 읽힙니다 |
| disabled | boolean | false | 버튼과 모든 액션이 응답을 멈춥니다 |
| onClick | MouseEventHandler<HTMLButtonElement> | — | 버튼을 눌렀을 때. 액션이 있어도 그대로 발생하며, 그때는 누름이 다이얼도 여닫습니다 |
| children | ReactNode | — | FloatingAction들, 있다면 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| icon | ReactNode | — | 글리프 |
| label * | string | — | 액션이 무엇을 하는지, 말로. 옆에 그려지고 언제나 읽힙니다 |
| disabled | boolean | false | 사용 불가. 다이얼에는 남습니다 |
| onClick | MouseEventHandler<HTMLButtonElement> | — | 액션을 눌렀을 때 |
나머지 <div> 속성은 루트로, 나머지 <button> 속성은 각 액션으로 전달됩니다. onClick은 버튼 자신의 것입니다.
공통 축(variant size color density elevation corner)의 의미는 Prop 규약에 있습니다.
예시
액션 펼치기
자식으로 FloatingAction을 넣으면 버튼은 다이얼이 됩니다. 누르거나 마우스를 올리면 액션들이 펼쳐지고, 글리프는 ×로 바뀝니다. 각 액션의 이름은 옆의 로젠지에 그려집니다.
closeOnAction은 액션을 누른 뒤 다이얼을 닫을지, showLabels는 이름을 그릴지, openOnHover는 마우스가 머무를 때 열지를 정합니다.
import { useState } from 'react';
import { FloatingAction, FloatingActionButton, Typography } from 'neba';
function LinkIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M6.5 9.5a2.75 2.75 0 0 0 4 .25l1.75-1.75a2.75 2.75 0 0 0-3.9-3.9L7.75 5.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M9.5 6.5a2.75 2.75 0 0 0-4-.25L3.75 8a2.75 2.75 0 0 0 3.9 3.9l.6-.6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function MailIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<rect
x="2.25"
y="3.75"
width="11.5"
height="8.5"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="m2.75 5 5.25 3.75L13.25 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
function PrintIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4.75 6.5v-3.5h6.5V6.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect
x="2.25"
y="6.5"
width="11.5"
height="5"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M4.75 9.5h6.5v3.75h-6.5V9.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
function ShareIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M8 10.5V2.75m0 0L5.25 5.5M8 2.75 10.75 5.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.25 9.5v3.25h9.5V9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export default function FloatingActionButtonDial() {
const [last, setLast] = useState('nothing yet');
return (
<div className="relative h-64 w-full max-w-sm overflow-hidden rounded-(--neba-radius-md) border [border-color:var(--neba-border)] p-4">
<Typography level="caption" className="text-(--neba-muted-fg)">
Last action: {last}
</Typography>
<FloatingActionButton position="absolute" icon={<ShareIcon />} label="Share">
<FloatingAction
icon={<LinkIcon />}
label="Copy link"
onClick={() => setLast('Copy link')}
/>
<FloatingAction icon={<MailIcon />} label="Email" onClick={() => setLast('Email')} />
<FloatingAction icon={<PrintIcon />} label="Print" onClick={() => setLast('Print')} />
</FloatingActionButton>
</div>
);
}extended
extended는 label을 글리프 옆에 써서 원을 스타디움으로 바꿉니다. 화면이 다루는 행동이 무엇인지 그림만으로 분명하지 않을 때 쓰는 형태입니다. label은 두 경우 모두 접근성 이름이므로, 그려지는 말과 읽히는 말이 어긋날 수 없습니다.
import { FloatingActionButton } from 'neba';
function PencilIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M11 2.5 13.5 5 6 12.5l-3.25.75L3.5 10 11 2.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
export default function FloatingActionButtonExtended() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<FloatingActionButton position="static" icon={<PencilIcon />} label="Compose" extended />
<FloatingActionButton
position="static"
icon={<PencilIcon />}
label="Compose"
extended
variant="outline"
/>
<FloatingActionButton position="static" icon={<PencilIcon />} label="Compose" />
</div>
);
}position, corner, offset
position은 기본이 fixed로 창의 모서리에 고정합니다. absolute는 가장 가까운 positioned 조상의 모서리에 고정하므로 카드나 지도, Mockup의 화면 안에 넣을 때 쓰고, static은 흐름 안으로 되돌립니다.
corner는 네 모서리 중 하나, offset은 양쪽 가장자리에서의 거리입니다. 액션이 펼쳐지는 방향은 corner에서 따라오며 direction으로 뒤집을 수 있습니다.
import { useState } from 'react';
import { FloatingAction, FloatingActionButton, Segment, SegmentedButton } from 'neba';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
function DotIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="4.25" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
const corners = ['top-start', 'top-end', 'bottom-start', 'bottom-end'] as const;
export default function FloatingActionButtonCorners() {
const [corner, setCorner] = useState<string | number | null>('bottom-end');
return (
<div className="flex w-full max-w-sm flex-col items-center gap-4">
<SegmentedButton size="sm" aria-label="Corner" value={corner} onValueChange={setCorner}>
{corners.map((value) => (
<Segment key={value} value={value}>
{value}
</Segment>
))}
</SegmentedButton>
<div className="relative h-56 w-full overflow-hidden rounded-(--neba-radius-md) border [border-color:var(--neba-border)]">
<FloatingActionButton
position="absolute"
corner={(corner ?? 'bottom-end') as (typeof corners)[number]}
offset={12}
icon={<PlusIcon />}
label="Add"
>
<FloatingAction icon={<DotIcon />} label="Task" />
<FloatingAction icon={<DotIcon />} label="Note" />
</FloatingActionButton>
</div>
</div>
);
}variant, size, color
버튼 자체는 Button이고, 변형과 elevation 사다리, 포인터 조명, 누름의 거동이 모두 그대로입니다. size만 한 칸 위에서 시작해 lg가 기본입니다. 보지 않고 엄지로 찾아 누르는 유일한 컨트롤이기 때문입니다. 액션들은 다시 한 칸 아래에서 그려집니다.
import { FloatingActionButton, Typography } from 'neba';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
export default function FloatingActionButtonAppearance() {
return (
<div className="flex flex-col items-center gap-5">
<div className="flex flex-wrap items-center justify-center gap-4">
{(['sm', 'md', 'lg', 'xl'] as const).map((size) => (
<FloatingActionButton
key={size}
position="static"
size={size}
icon={<PlusIcon />}
label={`Add, ${size}`}
/>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<FloatingActionButton position="static" icon={<PlusIcon />} label="Add" variant="outline" />
<FloatingActionButton
position="static"
icon={<PlusIcon />}
label="Add"
color="success"
elevation={3}
/>
<FloatingActionButton position="static" icon={<PlusIcon />} label="Add" disabled />
</div>
<Typography level="caption" className="text-(--neba-muted-fg)">
size defaults to lg, elevation to 2
</Typography>
</div>
);
}제어하기
open을 넘기면 다이얼은 자체 상태를 갖지 않습니다.
const [open, setOpen] = useState(false);
<FloatingActionButton label="공유" open={open} onOpenChange={setOpen}>
<FloatingAction icon={<LinkIcon />} label="링크 복사" />
</FloatingActionButton>;접근성
label은 필수입니다. 그림만으로 된 버튼은 접근성 이름이 아예 없습니다.- 액션이 있으면 버튼에
aria-expanded와, 펼쳐진 묶음을 가리키는aria-controls가 붙습니다.role="menu"는 아닙니다. 메뉴는 세트 전체에 tab 정지점 하나와 방향키 이동, typeahead를 약속하고, 그것이 필요하면 Menu가 그것입니다. - 액션들은 버튼 바로 다음 순서의 평범한 버튼이므로 tab으로 닿습니다.
- Escape는 다이얼을 닫고 focus를 버튼으로 되돌립니다. 바깥을 누르면 닫힙니다.
- 액션 옆 로젠지는
aria-hidden입니다. 같은 문자열이 이미 버튼의 이름이므로 두 번 읽히지 않게 합니다.