GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

플로팅 내비게이션 독 스타일 9종 — 호버 물결이 따라오는

플로팅 내비게이션은 화면 가장자리에 띄워 둔 작은 메뉴 막대이고, 아이콘 단추 몇 개와 지금 어느 칸인지 알려 주는 표시 하나로 이루어집니다. 아홉 개 중 하나는 이웃 아이콘까지 밀어내며 부풉니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기순이 아니라 표시가 차지하는 크기가 점점 커지는 순서로 놓았습니다. 처음 셋은 아이콘 위에 더해지는 것이 선·면·글자입니다. 밑줄 한 줄(01), 칸을 통째로 채우는 알약(02), 아이콘 위로 올라오는 이름표(03) 순서입니다. 네 번째(04)부터는 아이콘 자체의 크기가 표시가 되고, 이때부터 옆 아이콘의 자리까지 계산해야 합니다. 다섯째와 여섯째는 표시의 모양이 아니라 막대의 재질을 바꾼 쪽이라 나란히 두었습니다 — 흐린 유리(05)와 부드러운 그림자(06)는 겨냥하는 자리가 같은데 인상은 정반대입니다. 일곱째(07)는 밝은 배경에서 유리가 정말 유리로 보이는지 확인하는 판이고, 여덟째(08)는 독이 판으로 펼쳐지는 경우, 마지막(09)은 가로가 아니라 css 사이드바처럼 세로로 세운 경우입니다. 아홉 개 모두 라이브러리 없이 돌아가고, 선택 상태가 있는 판은 그것을 클래스가 아니라 aria-current나 aria-pressed 같은 속성에 담아 둡니다. 독이 화면을 가르는 전환 쪽은 CSS 화면 전환 효과에 있고, 메뉴가 한 줄을 넘어 사이트 지도가 되는 쪽은 메가 메뉴 ui가 맡습니다. 돌이 커지는 폭은 이웃까지 포함해 두 칸으로 제한했고, 라벨이 겹칠 때는 툴팁이 위로만 뜨도록 방향을 고정했습니다. 아홉 칸이 모두 라벨을 달면 아래 줄이 흔들리므로 라벨을 다는 칸은 다섯까지로 끊고, 물결이 남은 칸으로 이어지도록 딜레이는 40ms 간격으로 정했습니다.

01미니멀 라인 독

독 메뉴에서 무엇이 보이나

얇은 테두리 바 안에 아이콘 다섯 개가 놓이고, 누른 항목 아래에서 밑줄이 가운데부터 양옆으로 자랍니다.

미니멀 라인 독
새 창에서 열기

01 · 미니멀 라인 독 — 측정 누적면적 0.905% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

transformscaleXaria-current

코드에서 볼 곳

밑줄은 폭을 바꾸지 않고 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필 슬라이드 인디케이터

화면에서 일어나는 일

활성 칸을 채우는 알약 배경이 한 칸 폭만큼씩 옆으로 미끄러집니다.

필 슬라이드 인디케이터
새 창에서 열기

02 · 필 슬라이드 인디케이터 — 측정 누적면적 7.554% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@propertytranslateXflex

코드 읽는 법

한 칸은 아이콘 단추 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라벨 툴팁 위로

어떤 움직임인가

아이콘에 손을 올리면 이름표가 아이콘 위로 튀어 오릅니다.

라벨 툴팁
새 창에서 열기

03 · 라벨 툴팁 — 측정 누적면적 10.12% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scaleYsteps(1, end)transform-origin

어디를 바꾸면 되나

경첩은 이름표의 아래쪽 변이라 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아이콘 확대 물결

무엇이 보이나

커서에 가까운 아이콘이 가장 크게 부풀고 이웃은 거리에 따라 작아집니다. 어려운 건 배율이 아니라 밀림입니다.

아이콘 확대 물결
새 창에서 열기

04 · 아이콘 확대 물결 — 측정 누적면적 9.015% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@propertyscalepointermove

코드에서 볼 곳

부풀어 늘어난 폭을 앞 아이콘부터 누적해 나눠 주고 마지막에 전체 증가분의 절반을 빼야 줄이 가운데를 지킵니다.

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글래스모피즘 독

화면에서 일어나는 일

흐린 유리 막대 뒤에 보라·하늘색 원형 얼룩을 깔아 두었습니다. 유리는 뒤에 무언가 있어야 유리로 보이므로 얼룩이 장식이 아니라 재료입니다.

글래스모피즘 독
새 창에서 열기

05 · 글래스모피즘 독 — 측정 누적면적 6.322% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

backdrop-filterradial-gradienttranslateY

코드 읽는 법

활성 알약이 유리 위를 미끄러지고 그 아이콘만 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뉴모피즘 독

어떤 움직임인가

배경과 단추가 같은 색이고 그림자 한 쌍만으로 높낮이를 만듭니다.

뉴모피즘 독
새 창에서 열기

06 · 뉴모피즘 독 — 측정 누적면적 6.16% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

box-shadowinsetaria-pressed

어디를 바꾸면 되나

눌린 상태는 새 그림자를 그리는 것이 아니라 같은 그림자를 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반투명 블러 배지

무엇이 보이나

밝은 배경에서는 유리 효과가 자주 실패합니다. 흰 배지에 흰 배경이면 흐릴 것이 없기 때문입니다.

반투명 블러 배지
새 창에서 열기

07 · 반투명 블러 배지 — 측정 누적면적 30.672% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

backdrop-filterblurtranslateX

코드에서 볼 곳

그래서 색 띠 세 개를 배지 밑으로 지나가게 했고, 띠는 끝까지 갔다가 같은 길로 되돌아와 되감는 순간의 튐이 없습니다.

.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런치 패드 격자 확장

화면에서 일어나는 일

한 줄짜리 독이 일곱 칸 격자로 펼쳐집니다. 판 크기는 접히나 펴지나 같고 아이콘만 자리를 옮기므로, 아래에 붙은 단추가 들썩이지 않습니다.

런치 패드 격자 확장
새 창에서 열기

08 · 런치 패드 격자 확장 — 측정 누적면적 19.061% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@propertytranslatescale

코드 읽는 법

자리와 배율은 등록한 속성 세 개가 들고 있습니다.

.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씩 내려가고, 이름표는 아이콘 오른쪽으로 밀려 나옵니다.

세로 사이드 독
새 창에서 열기

09 · 세로 사이드 독 — 측정 누적면적 3.178% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateYopacityaria-current

어디를 바꾸면 되나

세로 가운데 정렬과 밀림이 둘 다 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 테두리로 떠 있는 느낌을 만드는 편이 낫습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.