GODRICH

CSS 입력창 검색창 포커스 애니메이션 9종 — 한 줄 복붙으로

CSS 입력창 애니메이션은 클릭하기 전·클릭한 순간·값을 다 채운 뒤까지 브라우저가 이미 쥐고 있는 :focus :focus-within :placeholder-shown :valid :invalid 상태를 그대로 움직임으로 옮기는 작업입니다.

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

아홉 개 전부 진짜 <input>을 썼고, 화려한 순이 아니라 입력창이 필요로 하는 판정이 늘어나는 순서로 놓았습니다. 01~05는 :focus :focus-within :placeholder-shown 만으로 끝나고, 06은 브라우저의 HTML5 유효성 검사(:valid/:invalid)가 판정을 대신하며, 07~09는 클릭이나 타이핑이 상태를 바꾸는 자리라 자바스크립트가 셀렉트 열기·글자 수 세기·눈 아이콘 토글처럼 CSS 상태만으로는 안 되는 세 곳에만 최소로 들어갑니다.

01플로팅 라벨

input 뒤에 형제로 둔 labelinput:focus이거나 값이 있어서 :not(:placeholder-shown)이면 왼쪽 위로 떠오르며 작아집니다. 플레이스홀더와 라벨을 하나로 합쳐 좁은 폼에서 줄 하나를 아낍니다.

:placeholder-showntransform scale220ms
.fl__label {
  position: absolute;
  left: $sp-4;
  top: 50%;
  transform: translateY(-50%) scale(1);
  transform-origin: left center;
  transition: transform $duration $easing, color $duration $easing;
}

.fl__input:focus ~ .fl__label,
.fl__input:not(:placeholder-shown) ~ .fl__label {
  transform: translateY(-24px) scale(.78);
  color: $color;
}

02밑줄 확장 포커스

테두리 없는 input 아래 span 하나가 밑줄 역할을 합니다. 부모 .ug:focus-within 이면 transform-origincenter로 둔 채 scaleX(0)에서 scaleX(1)로 자라 가운데서 좌우로 퍼지는 밑줄이 됩니다.

:focus-withinscaleX300ms
.ug__underline {
  height: 2px;
  background: $color;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform $duration $easing;
}

.ug:focus-within .ug__underline { transform: scaleX(1); }

03:has(:focus) 글로우

글로우 링은 input의 형제가 아니라 바깥에 따로 있는 span입니다. 부모 .fg:has(.fg__input:focus)로 자식의 포커스 여부를 알아채, 포커스가 유지되는 동안만 이 링이 opacity·scale로 숨 쉬듯 커졌다 작아집니다.

:has()opacity scale2s 루프
.fg__glow {
  border: 2px solid $color;
  opacity: 0;
  transform: scale(.92);
}

.fg:has(.fg__input:focus) .fg__glow {
  animation: glow-pulse $duration $easing infinite;
}

@keyframes glow-pulse {
  0%, 100% { opacity: .35; transform: scale(.92); }
  50%      { opacity: .9;  transform: scale(1.04); }
}

04검색창 확장

알약 모양 검색창은 평소 148px 로 좁게 접혀 있다가, label 전체가 :focus-within 이 되는 순간 240px 로 넓어집니다. 왼쪽 돋보기는 원과 선 하나로 그린 인라인 SVG 라 아이콘 폰트가 없어도 잘리지 않습니다.

:focus-withinwidth300ms
.se {
  width: 148px;
  transition: width $duration $easing, border-color $duration $easing;
}

.se:focus-within {
  width: 240px;
  border-color: $color;
}

05플레이스홀더 슬라이드

진짜 placeholder 속성은 빈 칸 하나로 비워 두고, 겹쳐 놓은 span이 화면에 보이는 안내문 역할을 대신합니다. input이 포커스되거나 값이 생기면 이 span이 오른쪽으로 밀리며 투명해집니다.

:placeholder-showntranslate opacity220ms
.ps__ghost {
  transform: translate(0, -50%);
  transition: transform $duration $easing, opacity $duration $easing;
}

.ps__input:focus ~ .ps__ghost,
.ps__input:not(:placeholder-shown) ~ .ps__ghost {
  transform: translate(10px, -50%);
  opacity: 0;
}

06오류 흔들림

type="email" required input이 값을 채운 뒤에도(:not(:placeholder-shown)) 형식이 틀리면 브라우저의 :invalid 판정만으로 테두리가 흔들리고 X 아이콘이 뜹니다. 형식이 맞으면 같은 자리에 체크 아이콘이 뜨는데, 자바스크립트는 한 줄도 쓰지 않았습니다.

:invalidtranslateX600ms
.ie__input:invalid:not(:placeholder-shown) {
  border-color: $error;
  animation: shake $duration $easing;
}
.ie__input:invalid:not(:placeholder-shown) ~ .ie__icon--bad { transform: translateY(-50%) scale(1); opacity: 1; }
.ie__input:valid:not(:placeholder-shown)   ~ .ie__icon--ok  { transform: translateY(-50%) scale(1); opacity: 1; }

@keyframes shake {
  20%, 80% { transform: translateX($amount); }
  30%, 70% { transform: translateX($amount * -1); }
  50%      { transform: translateX($amount * -.7); }
}

07커스텀 셀렉트 오픈

네이티브 select는 옵션 목록 디자인을 손댈 수 없어서, 이 항목은 buttonul[role=listbox]을 직접 짰습니다. 버튼 클릭이 is-open 클래스를 토글하는 5줄짜리 JS를 두고, 열릴 때 목록이 scaleY로 펼쳐지며 셰브론이 180도 돕니다.

aria-expandedscaleY220ms
.cs__list {
  transform: scaleY(0);
  transform-origin: top;
  opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.cs.is-open .cs__list { transform: scaleY(1); opacity: 1; }

.cs__chevron { transition: transform $duration $easing; }
.cs.is-open .cs__chevron { transform: rotate(180deg); }

08글자 수 카운트

남은 글자 수는 CSS 혼자 셀 수 없어서, textareainput 이벤트마다 숫자를 다시 쓰고 is-bump 클래스를 지웠다 붙이는 방식으로 튕기는 느낌을 재생합니다. aria-live="polite"를 걸어 두어 스크린 리더도 변경을 따라옵니다.

maxlengthscale bump140ms
.cc__num { display: inline-block; transform: scale(1); }
.cc__count.is-bump .cc__num { animation: bump $duration $easing; }

@keyframes bump {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.35); }
  100% { transform: scale(1); }
}

09비밀번호 눈 토글

눈 아이콘 버튼을 누르면 inputtype 자체가 passwordtext 사이를 실제로 오갑니다. 겹쳐 둔 눈 SVG와 사선 그은 눈 SVG는 is-shown 클래스에 따라 opacity·scale로 자리를 바꿔가며 크로스페이드합니다.

type toggleopacity scale220ms
.pw__icon {
  transform: scale(1);
  opacity: 1;
  transition: transform $duration $easing, opacity $duration $easing;
}
.pw__icon--off { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--eye { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--off { transform: scale(1); opacity: 1; }

어디서 깨지는가 — 함정 하나

03 글로우와 06 오류 표시는 :has() 로 부모가 자식의 상태를 감지합니다. Chrome·Edge 105, Safari 15.4, Firefox 121 미만에서는 셀렉터 자체가 무시돼 글로우가 안 뜨고 체크·X 아이콘도 안 바뀌지만, input 은 여전히 멀쩡히 값을 받고 :invalid 자체는 그대로 걸려서 기능이 완전히 죽지는 않습니다. 04 검색창 확장은 width 를 직접 전환하는 몇 안 되는 항목이라, 옆에 다른 요소를 붙여 두면 넓어지는 순간 그 요소가 실제로 밀리는 레이아웃 변화가 일어납니다 — 폭 240px 상한을 둔 이유도 그래서입니다. 이 zip 압축 비밀번호는 4qrx8ve2 이고, 아홉 개 항목 각각의 index.html에는 자동 재생용 스크립트 대신 사람의 클릭·타이핑만 남아 있습니다. 06 에서 required 를 빼면 빈 칸도 :invalid 로 잡혀 페이지를 열자마자 테두리가 붉게 보이니, :not(:placeholder-shown) 을 꼭 같이 걸어야 합니다.

접근성

01은 label for로 눈에 보이는 라벨을 스크린 리더용 라벨과 같은 요소로 연결했고, 05는 겹쳐 놓은 안내문 spanaria-hidden으로 감춰 화면에서만 보이게 하는 대신 스크린 리더에는 inputtype·autocomplete 정보만 전달됩니다. 06의 p role="alert"는 오류 문구가 나타나는 순간을 스크린 리더가 즉시 읽게 하고, 08의 aria-live="polite"는 급하지 않은 변경(글자 수)을 조용히 따라가게 합니다. 07·09는 aria-expanded·aria-pressed를 클릭마다 실제로 갱신해 버튼의 현재 상태를 알립니다. prefers-reduced-motion: reduce에서는 아홉 개 전부 transition·animation을 없애되 최종 상태(라벨 위치, 밑줄 길이, 열린 목록, 바뀐 아이콘)는 그대로 남겨 정보 손실이 없습니다.

@media (prefers-reduced-motion: reduce) {
  .fl__label { transition: none; }
  .ug__underline { transition: none; }
}

포커스·검색창 말고 다른 CSS 애니메이션은 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.

FAQ

이 아홉 개는 전부 CSS만으로 동작하나요?

01~06은 CSS 상태 선택자만으로 동작하고 자바스크립트가 없습니다. 07 커스텀 셀렉트 오픈, 08 글자 수 카운트, 09 비밀번호 눈 토글 세 개만 각각 5~9줄의 바닐라 JS가 있는데, 셋 다 CSS 혼자서는 절대 할 수 없는 일(목록 열림 토글, 글자 수 세기, type 속성 교체)이라 넣었습니다.

label 없이 placeholder만 써도 되나요?

안 됩니다. placeholder는 값을 입력하는 순간 사라지고 스크린 리더 지원도 라벨만큼 일관되지 않습니다. 01·04·06·08·09는 진짜 label을 뒀고(04는 labelinput을 통째로 감싸는 형태), 05는 placeholder를 빈 칸으로 비운 대신 화면에 보이는 안내문을 장식용 span(aria-hidden)에만 맡겨 스크린 리더에는 inputautocomplete 정보만 남습니다.

React 판에서도 :focus-within 같은 선택자가 그대로 동작하나요?

네. CSS Module로 컴파일돼도 :focus :focus-within :placeholder-shown :valid :invalid 같은 브라우저 상태 선택자는 그대로 살아 있습니다. React 쪽에서 useState로 관리하는 건 07의 열림 여부, 08의 입력값, 09의 표시 여부처럼 CSS가 알 수 없는 상태뿐이고, 포커스·유효성 판정은 여전히 브라우저가 합니다.

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

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