버튼 하나를 층으로 뜯어보기

hover가 좋아 보이는 이유를 CSS 층으로 분해해서, 한 겹씩 켜 보며 확인한다.

버튼이 "좋아 보인다"고 느낄 때, 그 인상은 대개 한 가지 속성이 아니라 서너 겹이 겹쳐서 만들어진다. 완성된 CSS를 통째로 읽으면 어느 줄이 무슨 일을 하는지 알기 어렵다. 그래서 한 겹씩 켜 보기로 했다.

아래 데모는 단계를 누를 때마다 그 단계의 CSS가 누적해서 얹힌다. 3번을 고르면 1+2+3이 겹친 상태다.

색은 어떻게 들어오는가

hover에 색이 바뀌는 버튼은 흔하다. 그런데 어떻게 바뀌는지가 인상을 가른다.

.btn { position: relative; overflow: hidden; padding: .8em 1.8em; border: 0; border-radius: 10px; background: #1a1a2e; color: #fff; font-size: 1rem; cursor: pointer; }
.btn::before { content: ""; position: absolute; inset: 0; background: linear-gradient(120deg, #6a5cff, #b06cff); opacity: 0; }
.btn > span { position: relative; }
.btn::before { transition: opacity .25s; }
.btn:hover::before { opacity: 1; }
.btn::before { opacity: 1; clip-path: inset(0 100% 0 0); transition: clip-path .35s cubic-bezier(.2, .7, .3, 1); }
.btn:hover::before { clip-path: inset(0 0 0 0); }

2단계에서 .btn > spanposition: relative를 준 게 핵심이다. ::before가 버튼을 통째로 덮기 때문에, 글자를 쌓임 맥락 위로 올려두지 않으면 그라데이션 밑에 깔려 사라진다. 2단계만 켜 보면 버튼이 여전히 남색인데, opacity: 0이라 아직 안 보일 뿐이다.

3단계와 4단계는 버튼에 마우스를 올린 채로 번갈아 눌러야 차이가 보인다. opacity전체가 동시에 밝아지고, clip-path왼쪽부터 차례로 드러난다. 눈이 따라갈 방향이 생기는 쪽이 더 비싸 보인다.

cubic-bezier(.2, .7, .3, 1)은 초반에 빠르고 끝에서 미끄러지듯 멈춘다. 4단계 값을 linear로 바꿔보면 기계가 밀어내는 느낌이 나고, ease-in으로 바꾸면 굼떠 보인다.

그림자는 몇 겹인가

그림자를 한 겹만 쓰면 버튼이 종이처럼 납작하다. 재질감은 밖으로 지는 그림자와 안으로 드는 빛을 같이 줄 때 생긴다.

.btn { padding: .8em 1.8em; border: 0; border-radius: 10px; background: linear-gradient(120deg, #6a5cff, #b06cff); color: #fff; font-size: 1rem; cursor: pointer; box-shadow: 0 4px 12px rgba(90, 70, 255, .4); }
.btn { box-shadow: 0 4px 12px rgba(90, 70, 255, .4), inset 0 1px 0 rgba(255, 255, 255, .45); }
.btn { transition: box-shadow .25s, translate .25s; }
.btn:hover { translate: 0 -2px; box-shadow: 0 10px 24px rgba(90, 70, 255, .55), inset 0 1px 0 rgba(255, 255, 255, .45); }

2단계에서 추가한 건 inset 0 1px 0 한 줄뿐이다. 버튼 윗변 안쪽에 1px짜리 흰 선을 그어 빛이 위에서 든 것처럼 보이게 한다. 1단계와 2단계를 왕복하면 같은 색인데 재질이 달라 보인다.

3단계도 마우스를 올려야 보인다. translate: 0 -2px는 그림자 확장과 짝을 이룬다. 떠오르는 만큼 그림자가 멀어져야 위로 뜬 것으로 읽힌다. 그림자를 그대로 두고 버튼만 올리면 그냥 어긋나 보인다.

테두리를 돌리려면 각도가 애니메이션돼야 한다

conic-gradient(from 0deg, ...)의 각도를 그냥 @keyframes로 바꾸려 하면 아무 일도 안 일어난다. 커스텀 속성은 기본적으로 문자열 취급이라 보간되지 않기 때문이다. @property로 타입을 알려줘야 비로소 각도가 부드럽게 돈다.

.btn { padding: .8em 1.8em; border: 4px solid #6a5cff; border-radius: 12px; background: #14142b; color: #fff; font-size: 1rem; cursor: pointer; }
.btn { border-color: transparent; background: linear-gradient(#14142b, #14142b) padding-box, conic-gradient(from 0deg, #6a5cff, #ff6ac1, #ffd76a, #6a5cff) border-box; }
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
@keyframes angle-lab-spin { to { --angle: 360deg; } }
.btn { background: linear-gradient(#14142b, #14142b) padding-box, conic-gradient(from var(--angle), #6a5cff, #ff6ac1, #ffd76a, #6a5cff) border-box; animation: angle-lab-spin 4s linear infinite; }

2단계의 padding-boxborder-box가 이 트릭의 전부다. 배경을 두 장 깔되 아래층 그라데이션은 테두리까지 칠하고, 위층 단색은 안쪽까지만 칠한다. 테두리를 transparent로 두면 그 틈으로 아래층이 비친다.

3단계가 하는 일은 고정된 0degvar(--angle)로 갈아끼우고 그 각도에 타입을 붙이는 것뿐이다. @property<angle>이라고 알려줘야 브라우저가 0deg와 360deg 사이를 보간할 수 있고, 그제야 @keyframes가 테두리를 돌린다. @property 줄만 지워보면 각도가 튀는 게 아니라 아예 멈춘다.

애니메이션 이름을 spin이 아니라 angle-lab-spin으로 지은 이유가 있다. @keyframes 이름은 문서 전역이라, 같은 글의 다른 데모가 spin을 쓰면 나중에 정의된 쪽이 이겨서 엉뚱한 데모가 따라 돈다.

정리

세 데모 모두 자바스크립트가 한 줄도 안 돈다. 단계 전환은 숨긴 라디오 버튼과 :has()가, 스타일 격리는 @scope가 맡는다. 브라우저가 원래 할 줄 아는 일에 얹었을 뿐이다.

완성된 CSS를 한 번에 보여주면 "그렇구나"로 끝나지만, 한 겹씩 켜 보면 어느 줄이 어느 인상을 만드는지 손에 남는다.