CSS 테두리 애니메이션 9종 — 한 줄 복붙으로
CSS 테두리 애니메이션은 카드나 입력창의 가장자리를 움직여 지금 상태를 알리는 기법입니다. 아홉 개를 한 번에 모았고, 대각선 줄무늬가 흐르는 것부터 SVG로 그려지는 것, 필터로 색이 순환하는 것까지 전부 다른 재료(마스크·필터·아웃라인·@property)를 씁니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 대각선 하이라이트 스윕 보더
- 02 SVG 보더 그리기
- 03 점선 이동
- 04 글로우 펄스 보더
- 05 코너 브래킷
- 06 밑줄 확장
- 07 이중 링 보더
- 08 무지개 보더
- 09 포커스 링 애니메이션
아홉 개는 테두리가 상태를 알리는 순서로 놓았습니다. 무언가 입력 중이거나 진행 중임을 보여주고(01~04), 포커스나 선택을 표시하고(05~06), 호버로 강조하고(07), 특별한 순간을 알리고(08), 마지막은 키보드 접근성입니다(09). 각 항목의 코드는 동작을 결정하는 조각만 잘라 보여드리고, 나머지 셋업까지 다 있는 파일은 zip 안에 들어 있습니다.
01대각선 하이라이트 스윕 보더
테두리 모양으로 잘라낸 얇은 링 위에 대각선 줄무늬 그러데이션을 깔고 계속 흘려보내 테두리 전체가 한꺼번에 빛나며 흐르는 것처럼 보입니다. AI가 응답을 만드는 동안의 입력창 같은 곳에 씁니다.
.frame::before {
content: "";
position: absolute; inset: 0; padding: 3px;
background: repeating-linear-gradient(115deg, $color 0 10px, $subject-cream 10px 14px, transparent 14px 34px);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: sweep $duration linear infinite;
}
@keyframes sweep { to { background-position: 220px 0; } }
02SVG 보더 그리기
카드 테두리를 SVG rect로 그리고 pathLength="100"을 줘서 실제 둘레 길이와 무관하게 0~100 단위로 다룹니다. stroke-dashoffset을 100에서 0으로 줄이면 펜으로 그리듯 이어지고, 카드가 로드되며 테두리가 그려지는 연출에 씁니다.
// pathLength="100" 인 rect 에 건다 — 실제 둘레 픽셀과 무관하게 0~100 단위로 잰다
.card__rect {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: draw-loop $dur-loop $easing infinite;
}
@keyframes draw-loop {
0%, 5% { stroke-dashoffset: 100; }
35%, 60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
03점선 이동
네 변에 각각 점선 모양 그러데이션 배경을 깔고, 변의 방향을 따라 background-position을 계속 옮기면 점선이 시계 방향으로 도는 것처럼 보입니다. 업로드나 처리 중인 카드 테두리에 씁니다.
.frame__edge--top, .frame__edge--bottom {
background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame.is-demo .frame__edge--top { animation: run-x $duration linear infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $duration linear infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-x-rev { to { background-position: -32px 0; } }
04글로우 펄스 보더
@property로 등록한 길이 값을 box-shadow의 번짐 반경에 걸면, 0에서 16px까지 중간값을 거치며 부드럽게 커졌다 작아집니다. 실시간으로 갱신되는 알림 배지에 씁니다.
@property --glow {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.badge {
box-shadow: 0 0 var(--glow) 0 rgba($color, .55);
animation: glow $duration $easing infinite;
}
@keyframes glow { 0%, 100% { --glow: 0px; } 50% { --glow: 16px; } }
05코너 브래킷
네 모서리에 L자 브래킷을 따로 두고, 호버하거나 포커스를 받으면 각자 대각선 방향으로 벌어져 카메라 초점 프레임처럼 보이게 합니다. 이미지 갤러리에서 고른 항목을 표시할 때 씁니다.
.frame__bracket--tl { top: -3px; left: -3px; border-right: 0; border-bottom: 0; }
.frame__bracket--tr { top: -3px; right: -3px; border-left: 0; border-bottom: 0; }
.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $easing infinite; }
@keyframes open-tl {
0%, 15% { transform: translate(0, 0); }
50%, 70% { transform: translate(-6px, -6px); }
100% { transform: translate(0, 0); }
}
06밑줄 확장
입력 필드 아래 인디케이터를 left: 50%; right: 50%로 가운데 접어 두고, 포커스를 받으면 둘 다 0으로 트랜지션해 양쪽으로 벌어지며 채워집니다. scaleX가 아니라 left·right를 쓴 건 별도의 transform-origin 없이도 가운데 기준 확장이 나오기 때문입니다.
.field__bar {
position: absolute;
left: 50%; right: 50%; bottom: -2px;
background: $color;
transition: left $duration $easing, right $duration $easing;
}
.field__input:focus + .field__bar { left: 0; right: 0; }
07이중 링 보더
box-shadow로 안쪽 고정 링을 두고, ::after로 만든 두 번째 링은 평소엔 숨겨 뒀다가 호버·포커스에 scale로 커지며 opacity가 0에서 1로 올라가 파문처럼 퍼지게 합니다. 버튼 호버 강조에 씁니다.
.btn::after {
content: "";
position: absolute; inset: -3px;
border: 2px solid $color;
opacity: 0;
transform: scale(1);
transition: transform $duration $easing, opacity $duration $easing;
}
.btn:hover::after, .btn:focus-visible::after { opacity: 1; transform: scale(1.3); }
08무지개 보더
border-image-source에 여러 색을 이은 고정 그러데이션을 걸어 두고, filter: hue-rotate 값만 0deg에서 360deg로 계속 돌립니다. 그러데이션 자체는 움직이지 않고 색만 자리에서 순환하는 게 01의 스윕과 다른 점입니다. 이벤트 배너나 축하 카드에 씁니다.
.badge {
border: 4px solid;
border-image-slice: 1;
border-image-source: linear-gradient(90deg, $error, $stage-orange, $stage-yellow, $subject-blue, $error);
animation: hue $duration linear infinite;
}
@keyframes hue { to { filter: hue-rotate(360deg); } }
09포커스 링 애니메이션
outline을 평소엔 투명으로 두고 :focus-visible에서만 outline-color와 outline-offset(0→4px)을 함께 트랜지션해 링이 튀어나오듯 벌어지게 합니다. 마우스 클릭에는 반응하지 않고 키보드 포커스에만 반응합니다.
.field__input {
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color $duration $easing, outline-offset $duration $easing;
}
.field__input:focus-visible { outline-color: $color; outline-offset: 4px; }
어디서 깨지는가 — 함정 셋
01은 mask-composite로 테두리 자리만 남기고 안쪽을 파냅니다. 이 속성은 접두사 없이 쓰면 Chrome·Edge 120 이상, Safari 15.4 이상, Firefox 53 이상에서만 동작합니다(MDN mask-composite). 그보다 오래된 Chrome은 -webkit-mask-composite: xor만 읽으므로, 두 선언을 같이 적어야 구형 브라우저에서도 안쪽이 파입니다. 하나만 적으면 최신 브라우저에서 만들고 테스트할 때는 멀쩡한데 구형 환경에서는 테두리가 통째로 채워진 사각형으로 보입니다.
이번 함정 말고도 짚어 둘 만한 지점이 두 곳 더 있습니다. 먼저 04의 @property입니다. 등록 없이 커스텀 프로퍼티만 애니메이션하면 브라우저가 시작값과 끝값 사이를 보간하지 못하고 중간 프레임 없이 뚝 바뀝니다. @property로 타입(<length>)을 선언해야 04의 번짐 반경이 부드럽게 커지고, 이 등록은 Chrome·Edge 85 이상, Safari 16.4 이상, Firefox 128 이상에서 지원합니다. 지금 설명한 조합을 그대로 반영해 둔 파일 묶음은 압축돼 있고, 열 때 묻는 암호는 4vruzyhz입니다. 아래 버튼을 누르면 내려받습니다.
다른 하나는 08의 filter입니다. filter는 자식 요소까지 통째로 다시 그리므로, 배지 안에 글자나 아이콘이 많으면 hue-rotate가 돌 때마다 그 영역 전체가 다시 합성됩니다. 무지개 보더는 배지처럼 작은 요소에만 쓰고, 카드 전체나 큰 컨테이너에는 붙이지 않는 게 안전합니다.
접근성
운영체제의 동작 줄이기 설정을 감지하는 prefers-reduced-motion: reduce를 아홉 항목 모두에 걸어 뒀습니다. 01·03·08은 흐르던 배경이 멈춰 정지된 무늬로 남고, 04는 번짐 반경이 중간값에서 고정됩니다. 09는 예외입니다. 키보드 포커스 링은 장식이 아니라 "지금 여기 있다"는 신호라서 트랜지션만 끄고 색은 그대로 유지합니다.
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before, .badge.is-demo, .field__bar { animation: none; transition: none; }
}
다른 CSS 애니메이션 주제는 CSS 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개 페이지에서 볼 수 있습니다.
FAQ
아홉 개 중 하나를 실제 서비스에 그대로 써도 되나요?
되지만 01·03·08처럼 계속 움직이는 것은 화면에 하나만 두는 걸 권합니다. 여러 개가 동시에 흐르면 어디를 봐야 할지 알 수 없어지고, 배터리를 쓰는 기기에서는 불필요한 리페인트가 쌓입니다.
mask-composite 대신 border-image로도 같은 효과를 낼 수 있나요?
부분적으로 됩니다. 08처럼 색만 있는 고정 그러데이션은 border-image로 깔끔하지만, 01처럼 배경을 흘려보내는 애니메이션은 border-image에 background-position 같은 위치 속성이 없어서 mask-composite나 SVG 쪽이 더 맞습니다.
React 버전에서 CSS 변수는 어떻게 넘기나요?
zip의 react/ 폴더는 duration·easing·color를 props로 받아 인라인 style의 CSS 변수로 넘깁니다. SCSS 모듈은 var(--duration)처럼 그 변수만 읽으므로, 값을 바꾸려면 컴포넌트를 쓰는 쪽에서 props만 바꾸면 됩니다.