플로팅 내비게이션 독 스타일 9종 — 호버 물결이 따라오는
플로팅 내비게이션은 화면 가장자리에 띄워 둔 작은 메뉴 막대이고, 아이콘 단추 몇 개와 지금 어느 칸인지 알려 주는 표시 하나로 이루어집니다. 아홉 개 중 하나는 이웃 아이콘까지 밀어내며 부풉니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 미니멀 라인 독
- 02 필 슬라이드 인디케이터
- 03 라벨 툴팁
- 04 아이콘 확대 물결
- 05 글래스모피즘 독
- 06 뉴모피즘 독
- 07 반투명 블러 배지
- 08 런치 패드 격자 확장
- 09 세로 사이드 독
아홉 개는 인기순이 아니라 표시가 차지하는 크기가 점점 커지는 순서로 놓았습니다. 처음 셋은 아이콘 위에 더해지는 것이 선·면·글자입니다. 밑줄 한 줄(01), 칸을 통째로 채우는 알약(02), 아이콘 위로 올라오는 이름표(03) 순서입니다. 네 번째(04)부터는 아이콘 자체의 크기가 표시가 되고, 이때부터 옆 아이콘의 자리까지 계산해야 합니다. 다섯째와 여섯째는 표시의 모양이 아니라 막대의 재질을 바꾼 쪽이라 나란히 두었습니다 — 흐린 유리(05)와 부드러운 그림자(06)는 겨냥하는 자리가 같은데 인상은 정반대입니다. 일곱째(07)는 밝은 배경에서 유리가 정말 유리로 보이는지 확인하는 판이고, 여덟째(08)는 독이 판으로 펼쳐지는 경우, 마지막(09)은 가로가 아니라 세로로 세운 경우입니다. 아홉 개 모두 라이브러리 없이 돌아가고, 선택 상태가 있는 판은 그것을 클래스가 아니라 aria-current나 aria-pressed 같은 속성에 담아 둡니다.
01미니멀 라인 독
얇은 테두리 바 안에 아이콘 다섯 개가 놓이고, 누른 항목 아래에서 밑줄이 가운데부터 양옆으로 자랍니다. 밑줄은 폭을 바꾸지 않고 scaleX만 0에서 1로 움직이므로 줄 전체가 다시 계산되지 않습니다.
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }
02필 슬라이드 인디케이터
활성 칸을 채우는 알약 배경이 한 칸 폭만큼씩 옆으로 미끄러집니다. 한 칸은 아이콘 단추 48px에 간격 8px을 더한 56px 고정값이고, 아이콘은 제자리에 그대로 있습니다.
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl__pill {
position: absolute;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn { flex: 0 0 auto; width: 48px; }
03라벨 툴팁 위로
아이콘에 손을 올리면 이름표가 아이콘 위로 튀어 오릅니다. 경첩은 이름표의 아래쪽 변이라 transform-origin: bottom center가 필요하고, 사라질 때는 흐려지지 않고 한 프레임에 꺼집니다.
.tt__tip {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
opacity: 0;
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
04아이콘 확대 물결
커서에 가까운 아이콘이 가장 크게 부풀고 이웃은 거리에 따라 작아집니다. 어려운 건 배율이 아니라 밀림입니다. 부풀어 늘어난 폭을 앞 아이콘부터 누적해 나눠 주고 마지막에 전체 증가분의 절반을 빼야 줄이 가운데를 지킵니다.
function wave(x) {
var s = btns.map(function (b) {
var c = b.offsetLeft + W / 2;
var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
var grown = s.map(function (v) { return (v - 1) * W; });
var total = grown.reduce(function (a, b) { return a + b; }, 0);
var before = 0;
btns.forEach(function (b, i) {
b.style.setProperty('--s', s[i].toFixed(3));
b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
before += grown[i];
});
}
05글래스모피즘 독
흐린 유리 막대 뒤에 보라·하늘색 원형 얼룩을 깔아 두었습니다. 유리는 뒤에 무언가 있어야 유리로 보이므로 얼룩이 장식이 아니라 재료입니다. 활성 알약이 유리 위를 미끄러지고 그 아이콘만 3px 들립니다.
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__dock {
border: 1px solid rgba(255, 255, 255, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(12px) saturate(1.6);
}
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
06뉴모피즘 독
배경과 단추가 같은 색이고 그림자 한 쌍만으로 높낮이를 만듭니다. 눌린 상태는 새 그림자를 그리는 것이 아니라 같은 그림자를 inset으로 뒤집은 것이고, 상태는 aria-pressed가 들고 있습니다.
.nu__btn {
border-radius: 50%;
background: $bg;
box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn[aria-pressed="true"] {
color: $color;
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
07반투명 블러 배지
밝은 배경에서는 유리 효과가 자주 실패합니다. 흰 배지에 흰 배경이면 흐릴 것이 없기 때문입니다. 그래서 색 띠 세 개를 배지 밑으로 지나가게 했고, 띠는 끝까지 갔다가 같은 길로 되돌아와 되감는 순간의 튐이 없습니다.
.bb__dock {
border: 1px solid rgba(255, 255, 255, .7);
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(8px);
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
@keyframes bb-drift {
0% { transform: translateX(0); }
50% { transform: translateX(420px); }
100% { transform: translateX(0); }
}
08런치 패드 격자 확장
한 줄짜리 독이 일곱 칸 격자로 펼쳐집니다. 판 크기는 접히나 펴지나 같고 아이콘만 자리를 옮기므로, 아래에 붙은 단추가 들썩이지 않습니다. 자리와 배율은 등록한 속성 세 개가 들고 있습니다.
.lp__btn {
position: absolute;
top: 50%;
left: 50%;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }
09세로 사이드 독
독을 왼쪽 가장자리에 세로로 세운 판입니다. 활성 점이 한 칸 36px씩 내려가고, 이름표는 아이콘 오른쪽으로 밀려 나옵니다. 세로 가운데 정렬과 밀림이 둘 다 transform이라 한 줄에 순서대로 적어야 뒤엣것이 앞엣것을 지우지 않습니다.
.sr__dot {
position: absolute;
width: 3px;
height: 16px;
background: $color;
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__label {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }
어디서 깨지는가 — 함정
아홉 개를 만들며 가장 많이 다시 손댄 곳은 애니메이션 자체가 아니라 두 요소가 같은 자리를 지나가는 순간이었습니다. 08번은 처음에 숨은 아이콘 넷을 독 가운데에서 각자의 칸으로 날려 보냈습니다. 코드로 보면 멀쩡한데 대표 이미지를 뽑아 보니 돋보기 아이콘 위에 사람 아이콘이 반쯤 겹친 장면이 나왔습니다. 앞 세 개가 윗줄로 올라가는 길과 숨은 넷이 날아오는 길이 겹쳤던 것이고, 두 무리가 같은 지점을 각각 다른 시각에 지나는 게 아니라 동시에 지나고 있었습니다. 숨은 넷을 처음부터 제 빈 칸에 놓고 배율 0으로 기다리게 한 뒤, 앞 세 개가 다 올라간 다음에야 커지도록 순서를 나누자 겹침이 사라졌습니다.
03번은 다른 얼굴의 같은 병이었습니다. 이름표가 서서히 흐려지며 사라지게 했더니 손이 옆 아이콘으로 옮겨 가는 동안 이름표 두 개가 반투명하게 포개져 둘 다 읽을 수 없었습니다. 사라짐을 steps(1, end)로 한 프레임에 끊자 그 구간이 통째로 없어졌습니다. 06번은 또 다른 이유로 두 번 고쳤습니다. 배경·단추·그림자가 전부 같은 색 계열이라 눌린 표시가 눈에 안 들어왔던 것인데, 어두운 쪽 그림자를 한 단계 진하게 하고 눌린 아이콘 색까지 바꾸자 변한 픽셀의 평균 차이가 19.5에서 22.8로 올라갔습니다. 세 가지 모두 어느 검사에도 걸리지 않고 사람 눈에만 "가끔 이상한 장면"으로 보이는 종류였습니다.
| 증상 | 진짜 원인 | 고치는 자리 |
|---|---|---|
| 아이콘 두 개가 포개진 장면이 찍힌다 | 두 무리의 이동 경로가 같은 시각에 겹친다 | 한쪽을 제자리에서 키우고 순서를 나눈다 |
| 이름표 두 개가 반투명하게 겹친다 | 사라짐이 페이드다 | opacity를 steps(1, end)로 끊는다 |
| 활성 표시가 아이콘을 비껴 선다 | 단추가 균등 분배라 실제 폭이 고정값과 다르다 | 단추에 flex: 0 0 auto와 고정 폭 |
| 표시가 칸 사이를 툭툭 건너뛴다 | 숫자 속성이 등록되지 않아 중간값이 없다 | @property로 syntax를 선언한다 |
표의 네 줄을 다 반영한 아홉 벌 소스는 압축 비밀번호 wd6vwhyu를 넣으면 열리는 zip에 들어 있고, 바닐라와 React 판이 같은 변수 세 개를 씁니다.
접근성
선택 상태가 있는 여섯 개는 그것을 클래스가 아니라 속성에 담았고, 나머지 03·04·07번은 선택 상태 없이 손이 올라간 동안만 반응합니다. 01·02·05·09번은 aria-current="page", 06번은 aria-pressed, 08번의 토글 단추는 aria-expanded와 aria-controls를 씁니다. 이렇게 해 두면 화면 낭독기가 "지금 선택됨"을 읽어 주고, 02·05·06·09번은 CSS까지 그 속성 선택자로 그리므로 보이는 것과 전해지는 것이 어긋날 수 없습니다(01번만 속성과 .on 클래스를 함께 옮깁니다). 03번 이름표는 aria-hidden="true"이고 대신 화면에서 감춘 텍스트를 단추 안에 따로 넣었습니다. 같은 말이 두 번 읽히지 않게 하기 위해서입니다. 아이콘만 있는 단추에는 aria-label을 붙였고 03·09번은 단추 안의 글자가 그 이름을 대신하며, 모든 단추가 :focus-visible에서 윤곽선을 남기므로 키보드로 탭할 때 지금 어디 있는지 보입니다. 아이콘은 전부 Phosphor Icons의 듀오톤 SVG를 인라인으로 넣은 것이라 글꼴도 CDN도 부르지 않습니다. prefers-reduced-motion: reduce에서는 자동 재생 루프와 미끄러짐·튀어오름만 끄고 활성 표시 자체는 그대로 남습니다. 어느 칸인지는 연출이 아니라 정보이기 때문입니다. 화면 낭독기가 현재 위치를 어떻게 읽는지는 MDN의 aria-current 문서에 정리돼 있습니다.
같은 계열을 더 찾는다면 호버 카테고리와 랜딩 페이지 카테고리에 함께 쓰기 좋은 부품이 모여 있습니다.
FAQ
활성 표시를 고정 px로 옮기면 화면 크기가 바뀔 때 어긋나지 않나요?
어긋납니다. 단, 어긋나는 건 화면 크기가 아니라 단추 폭이 유동일 때입니다. 02번처럼 translateX(calc(var(--idx) * 56px))로 옮기려면 단추가 flex: 0 0 auto에 고정 폭이어야 하고, 그러면 화면이 좁아져도 한 칸 폭은 56px 그대로입니다. 반대로 flex: 1 1 0으로 남은 폭을 나눠 갖게 하면 실제 렌더 폭이 매번 달라져서 알약이 옆 칸에 걸칩니다. 좁은 화면에서 줄이려면 미디어 쿼리로 단추 폭과 간격, 그리고 한 칸 값을 함께 바꿔야 합니다.
아이콘이 커질 때 이웃을 밀어내는 계산은 왜 필요한가요?
배율만 주면 아이콘이 서로 겹치기 때문입니다. scale은 요소가 차지하는 자리를 바꾸지 않고 그리는 크기만 바꾸므로, 1.8배로 부푼 아이콘은 제자리를 넘어 양옆 아이콘 위로 번집니다. 04번이 각 아이콘이 늘어난 폭을 앞에서부터 누적해 이동값으로 나눠 주는 이유가 이것이고, 마지막에 전체 증가분의 절반을 빼는 것은 줄 전체가 오른쪽으로 밀려나지 않게 하기 위해서입니다.
흐린 유리 독이 배경에 따라 밋밋해 보입니다
backdrop-filter는 뒤에 있는 그림을 흐리는 필터라 뒤가 단색이면 아무 일도 일어나지 않습니다. 05번이 색 얼룩 두 개를 독 뒤에 깐 것, 07번이 색 띠를 배지 밑으로 지나가게 한 것이 모두 그래서입니다. 배경이 흰 화면이라면 유리 대신 아주 옅은 그림자와 1px 테두리로 떠 있는 느낌을 만드는 편이 낫습니다.