플로팅 버튼 아이콘 버튼 패턴 9종 — 누르고 끌고 펼쳐지는
플로팅 버튼은 화면 한 귀퉁이에 떠 있으면서 그 화면의 대표 동작 하나를 맡는 둥근 버튼입니다. 아홉 개 대부분 글자 없이 그림 하나로 말을 걸어야 하므로, 무엇을 하는 버튼인지 보조기기에도 들리게 이름을 달았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 45도로 돌아 동작을 꺼내는 스피드 다이얼
- 02 스크롤 방향을 읽고 숨는 툴바 FAB
- 03 눌린 자리에서 번지는 아이콘 리플
- 04 선을 그려 채우는 즐겨찾기 별
- 05 해와 달이 맞바뀌는 테마 버튼
- 06 링이 빙글 도는 닫기 버튼
- 07 아이콘이 입력칸으로 펼쳐지는 검색 FAB
- 08 누르면 채팅 창이 열리는 도움말 버튼
- 09 길게 눌러 끌면 따라오는 FAB
순서는 버튼이 화면에서 맡는 일의 크기 순입니다. 먼저 화면 전체의 대표 동작을 쥔 둘이 옵니다. 하나를 눌러 여러 갈래를 꺼내고(01), 읽는 동안에는 스스로 비켜섭니다(02). 다음 넷은 누름에 답하는 방식이 서로 다릅니다. 눌린 지점에서 물결이 번지고(03), 별은 선을 그린 뒤 색을 채우며(04), 해와 달은 자리를 맞바꾸고(05), 닫기는 링을 돌립니다(06). 마지막 셋은 버튼이 자기 모양이나 자리를 바꾸는 쪽입니다. 아이콘이 입력칸으로 펼쳐지고(07), 채팅 판을 열어 붙들고(08), 길게 눌러 끌면 버튼 자체가 옮겨 갑니다(09).
0145도로 돌아 동작을 꺼내는 스피드 다이얼
만들기 버튼 하나에 글쓰기·사진·녹음을 다 담으려면 누른 다음에 갈라져야 합니다. 이 부품은 더하기 아이콘을 rotate(45deg) 로 돌려 닫기 표시로 바꾸고, 보조 버튼 셋을 transition-delay 로 40밀리초씩 어긋나게 띄웁니다. 열림과 닫힘은 클래스와 함께 aria-expanded 에도 적히고, Escape 로도 닫힙니다.
.sd__sub {
transform: translateY(12px) scale(.6);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(.2, 1.4, .4, 1),
opacity 140ms linear;
}
.sd.is-open .sd__sub {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.sd__item:nth-child(2) .sd__sub { transition-delay: 40ms; }
.sd__item:nth-child(3) .sd__sub { transition-delay: 80ms; }
02스크롤 방향을 읽고 숨는 툴바 FAB
읽는 동안 도구가 본문을 가리면 도구가 아니라 방해입니다. 이 부품은 목록의 scrollTop 이 커지면 내려가는 중으로 보아 버튼 띠를 translateY(150%) 로 치우고, 값이 작아지면 도로 올립니다. 2픽셀 문턱을 두어 손가락이 살짝 떨릴 때는 반응하지 않습니다.
var tbLast = tbView.scrollTop;
tbView.addEventListener('scroll', function () {
var now = tbView.scrollTop;
var down = now - tbLast > 2;
var up = tbLast - now > 2;
tbLast = now;
if (down && !tb.classList.contains('is-hidden')) {
tb.classList.add('is-hidden');
} else if (up && tb.classList.contains('is-hidden')) {
tb.classList.remove('is-hidden');
}
});
03눌린 자리에서 번지는 아이콘 리플
원형 버튼의 한가운데에서만 물결이 퍼지면 어디를 눌렀는지가 드러나지 않습니다. 이 부품은 pointerdown 이 잡은 좌표를 원의 --x 와 --y 에 적어, 정확히 그 점에서 원이 scale 로 커지며 옅어집니다. 커지는 것은 transform 이라 레이아웃을 다시 계산하지 않습니다.
rpBtn.addEventListener('pointerdown', function (e) {
var box = rpBtn.getBoundingClientRect();
var dot = document.createElement('span');
dot.className = 'rp__wave';
dot.style.setProperty('--x', (e.clientX - box.left) + 'px');
dot.style.setProperty('--y', (e.clientY - box.top) + 'px');
rpBtn.appendChild(dot);
window.setTimeout(function () { dot.remove(); }, 620);
});
04선을 그려 채우는 즐겨찾기 별
별이 한순간에 노래지면 담았다는 사실만 남고 담는 동작은 사라집니다. 이 부품은 테두리를 stroke-dashoffset 으로 100에서 0까지 한 바퀴 그린 뒤, 선이 다 그려지는 460밀리초 지점에서 채워진 별을 한 프레임에 갈아 끼웁니다. 누름은 aria-pressed 에 남고 카드 바탕도 같은 순간에 물듭니다.
.st__draw path {
fill: none;
stroke: #17141a;
stroke-width: 10;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset 460ms ease-out;
}
.st__fill { opacity: 0; transition: opacity 0ms 460ms; }
.st.is-on .st__draw path { stroke-dashoffset: 0; }
.st.is-on .st__fill { opacity: 1; }
05해와 달이 맞바뀌는 테마 버튼
해와 달을 투명도로 섞으면 중간에 둘이 반쯤 겹친 유령 장면이 생깁니다. 이 부품은 두 아이콘을 grid-area: 1 / 1 로 겹쳐 두고 transition: opacity 0ms 로 한 프레임에 잘라 맞바꾸며, 안쪽 미리보기 판은 steps(1, end) 로 같은 순간에 낮과 밤을 갈아 끼웁니다. 회전과 크기는 이징을 그대로 두어, 자르는 것은 보임뿐이고 도는 것은 부드럽습니다.
@keyframes tm-loop-sun {
0%, 36% { opacity: 1; transform: rotate(0deg) scale(1); }
52%, 78% { opacity: 0; transform: rotate(90deg) scale(.4); }
94%, 100% { opacity: 1; transform: rotate(0deg) scale(1); }
}
.tm.is-demo .tm__card {
animation-name: tm-loop-card;
animation-timing-function: steps(1, end);
}
06링이 빙글 도는 닫기 버튼
닫기 표시는 작아서 눌러도 되는지 애매할 때가 많습니다. 이 부품은 점선 링을 두르고 마우스를 올리거나 :focus-visible 로 초점이 닿을 때만 링을 돌립니다. 키보드로 온 사람과 마우스로 온 사람이 같은 신호를 받고, 손을 떼면 링은 곧바로 멈춥니다.
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring {
opacity: 1;
animation-name: cl-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
07아이콘이 입력칸으로 펼쳐지는 검색 FAB
돋보기만 있던 자리가 입력칸이 되려면 폭이 흘러야 합니다. 이 부품은 --w 를 @property 로 길이형으로 등록해 두고 48픽셀에서 264픽셀까지 transition 으로 흐르게 합니다. 다 펼쳐진 뒤 초점은 preventScroll 과 함께 입력칸으로 넘어가고, 열림은 aria-expanded 로 알립니다.
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 48px;
}
.se__pill {
--w: 48px;
width: var(--w);
transition: --w 320ms cubic-bezier(.2, .8, .2, 1),
border-color 140ms linear;
}
08누르면 채팅 창이 열리는 도움말 버튼
문의 버튼이 새 페이지로 넘겨 버리면 보고 있던 화면을 잃습니다. 이 부품은 같은 화면 위에서 판을 translateY 로 올려 열고, 보낸 말은 진짜 말풍선으로 쌓입니다. 판이 열릴 자리는 미리 확보해 두어 열려도 아래 칸이 밀리지 않습니다.
chFab.addEventListener('click', function () {
ch.classList.toggle('is-open');
var open = ch.classList.contains('is-open');
chFab.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) window.setTimeout(function () {
chInput.focus({ preventScroll: true });
}, 240);
});
09길게 눌러 끌면 따라오는 FAB
떠 있는 버튼이 하필 읽고 싶은 글자를 가릴 때가 있습니다. 이 부품은 350밀리초보다 오래 눌러야 붙잡기로 넘어가고, 그 뒤로는 버튼 자체가 손가락을 따라 translate 로 움직입니다. 놓으면 좌우 중 가까운 가장자리로 스냅되고, 짧게 누르는 탭은 평소의 클릭 그대로 남습니다.
dgFab.addEventListener('pointerdown', function (e) {
dgHold = window.setTimeout(function () {
dgGrab = true;
dgFab.classList.add('is-grab');
dgScreen.classList.add('is-holding');
try { dgFab.setPointerCapture(e.pointerId); } catch (err) { }
}, 350);
});
dgFab.addEventListener('pointermove', function (e) {
if (!dgGrab) return;
var box = dgScreen.getBoundingClientRect();
var maxX = box.width - dgFab.offsetWidth - 8;
var x = Math.min(Math.max(e.clientX - box.left - dgFab.offsetWidth / 2, 0), maxX);
dgFab.style.transform = 'translateX(' + x + 'px)';
});
어디서 깨지는가 — 함정
가장 자주 깨지는 곳은 글자 없는 버튼에 이름을 안 다는 것입니다. 아홉 개 중 여섯 개는 아이콘 하나뿐이라, 이름을 빼면 낭독기에서는 그냥 「버튼」으로 들립니다. 그래서 전부 aria-label 을 달았고, 상태가 바뀌면 이름도 같이 바꿉니다. 01 의 FAB 은 닫혀 있을 때 「만들기 메뉴 열기」, 열려 있을 때 「만들기 메뉴 닫기」입니다. 07 의 검색 FAB 도 같은 방식이고, 05 의 테마 버튼은 지금 무엇을 누르면 무엇이 되는지를 이름에 적었습니다.
두 번째는 한 번에 꺼지고 켜지는 전환입니다. 05 를 처음 만들 때는 해와 달을 투명도로 교차시켰는데, 24장을 뽑는 미리보기에서 두 아이콘이 반쯤 겹친 장면이 그대로 대표 그림이 되었습니다. 그래서 낮과 밤 판의 보임은 steps(1, end) 로 한 프레임에 자르고, 해와 달은 회전과 크기를 이징에 맡겨 겹치는 순간에도 각도가 갈리게 했습니다. 그렇게 하면 컷만으로는 변한 장이 두세 장밖에 안 되는 문제가 따라옵니다. 05 는 컷 앞뒤로 원반이 180도 도는 구간을 넓게 잡아 24장 중 10장이 움직이게 맞췄습니다.
세 번째는 사라지는 장이 대표 그림이 되는 것입니다. 01 은 열림이 주제인데, 보조 버튼 셋이 한꺼번에 사라지는 장의 변화가 더 커서 처음에는 빈 화면이 대표로 뽑혔습니다. 퇴장을 78·84·90퍼센트로 한 장씩 떼어 놓고 사라질 때 크기까지 줄이자 열린 장이 남았습니다. 06 은 반대 문제였습니다. 링만 도는 부품이라 변한 넓이가 아홉 개 중 가장 작아, 카드를 288픽셀에서 252픽셀로 줄이고 버튼을 44픽셀에서 56픽셀로 키워 링이 화면에서 차지하는 몫을 늘렸습니다. 그러고도 넓이는 1.83퍼센트로 아홉 개 중 가장 작지만, 움직인 장이 24장 중 23장으로 아홉 개 중 가장 많아 도는 것 자체는 끊기지 않고 읽힙니다. 07 도 같은 이유로 무대를 흰색에서 주황으로 옮겼습니다. 흰 무대에 흰 알약이 놓이니 펼쳐지는 폭이 화면에서 읽히지 않았고, 무대를 바꾸자 변한 넓이가 8.00퍼센트에서 10.81퍼센트로, 변한 픽셀의 평균 차이가 90.8에서 125.3으로 올라갔습니다.
마지막은 좁은 화면입니다. 부품은 넓은 화면에서 480×300 칸에 들어가고 폰에서는 320×200으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 것은 174픽셀뿐이라, 320픽셀에서는 간격을 키우지 않고 줄였습니다. 01 의 보조 버튼은 최소 폭을 168픽셀에서 140픽셀로, 08 의 채팅 판은 높이를 148픽셀에서 112픽셀로 줄입니다. 09 는 좁은 화면에서 버튼이 작아지는 만큼 스냅이 닿는 자리도 달라지므로, 끝 위치를 고정값으로 적어 두지 않고 화면 폭에서 버튼 폭을 뺀 값으로 그때그때 계산합니다. 아홉 벌을 한 폴더에 담아 두었고 그 압축 파일의 비밀번호는 khf267ta 이며, 안에는 지금 보는 바닐라 판과 같은 동작의 React 판이 함께 들어 있습니다.
접근성
움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)이 켜진 화면에서는 아홉 벌 전부 반복 루프가 멈추고 결과 상태만 남습니다. 스피드 다이얼은 펼쳐진 채로, 별은 다 그려져 채워진 채로, 테마 버튼은 달이 뜬 채로 섭니다. 움직임이 사라져도 「지금 어떤 상태인가」는 남아야 하기 때문입니다.
아이콘 버튼의 접근성은 이름과 상태 두 가지로 정리됩니다. 이름은 aria-label 로 주고, 여닫이는 aria-expanded(01·07·08), 켜짐과 꺼짐은 aria-pressed(04·05)로 알립니다. 초점 처리도 빼놓지 않았습니다. 07 과 08 이 입력칸에 초점을 줄 때 preventScroll 을 함께 쓰는 이유는 이 부품들이 목록 화면의 iframe 안에 들어 있어, 가드 없이 부르면 부모 페이지가 통째로 끌려가기 때문입니다. 06 은 마우스를 올렸을 때와 키보드 초점이 닿았을 때 같은 신호를 주도록 :focus-visible 을 호버와 나란히 묶었습니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 스피드 다이얼 | 45도 회전 + 세 갈래 | aria-expanded 토글 |
클릭 · Escape |
| 02 툴바 FAB | 아래로 치워짐 | 버튼마다 aria-label |
스크롤 |
| 03 아이콘 리플 | 누른 점에서 번짐 | 알림 버튼 이름 | 포인터 · Enter |
| 04 즐겨찾기 별 | 선 그리기 뒤 채움 | aria-pressed · 담김 안내 |
클릭 |
| 05 테마 버튼 | 해와 달 맞바뀜 | aria-pressed · 이름 바뀜 |
클릭 |
| 06 닫기 링 | 링이 돌기 시작 | 닫기 버튼 이름 | 호버 · Tab 초점 |
| 07 검색 FAB | 아이콘이 칸으로 | aria-expanded · 칸 이름 |
클릭 · Escape |
| 08 도움말 FAB | 판이 올라옴 | aria-live 로 답 안내 |
클릭 · Enter |
| 09 제스처 FAB | 끌린 자리로 스냅 | 위치 안내 문구 | 길게 눌러 끌기 · ←→ |
아이콘만 있는 버튼에 이름을 주는 방법은 MDN 의 aria-label 문서에 정리되어 있습니다. 같은 계열의 버튼 부품은 CTA·버튼 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.
FAQ
아이콘만 있는 버튼에 글자를 꼭 넣어야 하나요?
화면에 보이는 글자는 없어도 되지만, 이름은 반드시 있어야 합니다. aria-label 로 주면 눈에는 안 보이고 낭독기에만 들립니다. 이 아홉 개 중 02 의 세 버튼처럼 나란히 붙은 아이콘들은 특히 그렇습니다. 화살표·돋보기·줄 세 개가 나란히 있으면 보는 사람은 구분하지만 듣는 사람에게는 「버튼 버튼 버튼」입니다. 이름은 「맨 위로」처럼 그 버튼을 누르면 무슨 일이 생기는지로 적습니다.
아이콘 색은 얼마나 진해야 하나요?
아이콘은 글자가 아니라 그림이라 기준이 다릅니다. 버튼의 뜻을 전하는 그림은 배경과 3 대 1 이상이면 되고, 본문 글자에 요구되는 4.5 대 1은 여기에 해당하지 않습니다. 이 편에서 실제로 잰 값으로 보면 06 의 흰 카드 위 먹색 링이 18.24 대 1로 가장 높고, 03 의 먹색 버튼 위 크림색 종 아이콘이 17.11 대 1로 그다음이며, 01 과 07 의 파란 FAB 위 크림색 아이콘은 4.25 대 1로 가장 낮지만 여전히 기준 위입니다. 예외는 04 의 채워진 별입니다. 노랑과 흰 카드는 1.44 대 1뿐이라 색만으로는 형태가 안 잡히는데, 그 별에 먹색 테두리를 10 두께로 남겨 두어 윤곽이 대비를 책임집니다.
플로팅 버튼은 화면 어디에 두는 게 좋나요?
오른손 엄지가 닿는 오른쪽 아래가 기본이지만 고정된 규칙은 아닙니다. 왼손잡이거나 화면이 큰 폰에서는 그 자리가 오히려 멀고, 하단 탭 막대가 있으면 겹칩니다. 09 를 만든 이유가 그것입니다. 자리를 우리가 정해 주는 대신 길게 눌러 옮기게 하고, 놓으면 가까운 가장자리에 붙게 했습니다. 옮길 수 없다면 최소한 하단 막대 높이만큼은 띄워 둡니다.