CSS 화살표 애니메이션 9종
화살표 아이콘이 저절로 움직이는 웹사이트를 본 적 있으신가요? 마우스를 올리면 화살표가 길어지거나, 버튼을 눌러 보라는 듯 통통 튀거나 — 이런 작은 움직임 하나가 방문자의 시선을 붙잡아 줍니다.
01 arrow-animation-arrow-extend
02 arrow-animation-arrow-slide-loop
03 arrow-animation-arrow-bounce-down
04 arrow-animation-arrow-circle-hover
05 arrow-animation-arrow-rotate-45
06 arrow-animation-arrow-draw-svg
07 arrow-animation-arrow-chevron-wave
08 arrow-animation-arrow-mouse-scroll
09 arrow-animation-arrow-double-swap
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 길게 늘어나는 화살표
- 02 계속 흐르는 화살표
- 03 아래로 바운스
- 04 원+화살표 호버
- 05 외부 링크 45도 회전
- 06 선이 그려지는 화살표
- 07 셰브론 웨이브
- 08 마우스 모양 스크롤 안내
- 09 두 화살표 교차
아홉 개는 화살표가 화면에서 맡는 역할 순서를 따라갑니다. 먼저 링크 옆에서 방향을 가리키는 것들(01·05)을 지나, 호버 없이도 계속 움직이며 눈길을 끄는 자동 재생 화살표(02·03·07·08)로, 그 사이 버튼 위에서 반응하는 것(04)과 손으로 그리듯 나타나는 장식형(06)을 섞은 뒤, 마지막은 방향을 서로 맞바꾸는 교차형(09)으로 끝납니다. 화살표가 통통 튀며 방향을 알려 주는 쪽이 궁금하면 통통 튀는 애니메이션이 그 낙하를 만들었고, 링크 위에서 밑줄이 그어지는 움직임은 CSS 밑줄 애니메이션이 짝입니다.
01길게 늘어나는 화살표
무엇이 움직이나
"더보기" 링크에 마우스를 올리면 화살표의 몸통이 옆으로 길게 늘어나고 화살촉이 그만큼 밀려나, 다음 페이지로 넘어가라고 부드럽게 재촉합니다. 늘어나는 순간의 이징을 고무줄식 오버슛 곡선(cubic-bezier(.34, 1.56, .64, 1))으로 바꿔 목표치를 살짝 넘었다가 제자리로 정착하게 했고, 화살촉 뒤로는 작은 점 두 개가 지연되어 흐리게 따라붙는 트레일이 붙습니다.

<main class="stage">
<a class="ae-link is-demo" href="#">
<span class="ae-link__label">Learn more</span>
<span class="ae-link__arrow">
<span class="ae-link__shaft"></span>
<span class="ae-link__head"></span>
<span class="ae-link__trail ae-link__trail--1" aria-hidden="true"></span>
<span class="ae-link__trail ae-link__trail--2" aria-hidden="true"></span>
</span>
</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // is-demo 강제 루프 주기(캡처용). 실제 호버 전환은 아래 220ms 를 쓴다
$easing: $ease-elastic; // 고무줄식 오버슛 — 늘어났다 살짝 되돌아와 멈춘다
$color: $subject-cream;
@include stage($stage-ink);
.ae-link {
display: inline-flex;
align-items: center;
gap: $sp-2;
color: $color;
text-decoration: none;
font: 600 16px/1 -apple-system, "Segoe UI", sans-serif;
}
.ae-link__arrow {
position: relative;
display: inline-flex;
align-items: center;
width: 26px;
height: 12px;
}
.ae-link__shaft {
position: absolute;
left: 0;
width: 16px;
height: 2px;
background: $color;
transform-origin: left center; // 규칙8: 화살대 왼쪽서 늘어남
transform: scaleX(1);
transition: transform 260ms $easing;
}
.ae-link__head {
position: absolute;
left: 10px;
width: 8px;
height: 8px;
border-top: 2px solid $color;
border-right: 2px solid $color;
transform: translateX(0) rotate(45deg);
transition: transform 260ms $easing;
}
// 화살촉 트레일 파티클 — 신장하는 동안 화살촉 뒤로 작은 점 두 개가 지연·감쇠하며 따라붙는다
.ae-link__trail {
position: absolute;
left: 10px;
width: 4px;
height: 4px;
border-radius: 50%;
background: $color;
opacity: 0;
}
// 실사용: 링크에 마우스를 올리면 몸통(shaft)이 길게 늘어나고 화살촉(head)이 따라 밀려난다. $easing 이 고무줄 오버슛을 만든다
.ae-link:hover .ae-link__shaft { transform: scaleX(1.8); }
.ae-link:hover .ae-link__head { transform: translateX(10px) rotate(45deg); }
@keyframes ae-trail-hover {
0% { opacity: 0; transform: translateX(0) scale(.6); }
45% { opacity: .5; transform: translateX(5px) scale(1); }
100% { opacity: 0; transform: translateX(9px) scale(.4); }
}
.ae-link:hover .ae-link__trail--1 { animation: ae-trail-hover 420ms $ease-out forwards; }
.ae-link:hover .ae-link__trail--2 { animation: ae-trail-hover 420ms $ease-out 70ms forwards; }
// 데모 루프: is-demo 는 :hover 를 흉내 낸 keyframes 로 무한 반복 (헤드리스는 :hover 를 못 건다)
@keyframes ae-extend-shaft {
0%, 20% { transform: scaleX(1); }
50%, 70% { transform: scaleX(1.8); }
100% { transform: scaleX(1); }
}
@keyframes ae-extend-head {
0%, 20% { transform: translateX(0) rotate(45deg); }
50%, 70% { transform: translateX(10px) rotate(45deg); }
100% { transform: translateX(0) rotate(45deg); }
}
@keyframes ae-trail-loop {
0%, 20% { opacity: 0; transform: translateX(0) scale(.6); }
38% { opacity: .5; transform: translateX(5px) scale(1); }
50%, 70% { opacity: 0; transform: translateX(9px) scale(.4); }
100% { opacity: 0; transform: translateX(0) scale(.6); }
}
.ae-link.is-demo .ae-link__shaft { animation: ae-extend-shaft $duration $easing infinite; }
.ae-link.is-demo .ae-link__head { animation: ae-extend-head $duration $easing infinite; }
.ae-link.is-demo .ae-link__trail--1 { animation: ae-trail-loop $duration $ease-out infinite; }
.ae-link.is-demo .ae-link__trail--2 { animation: ae-trail-loop $duration $ease-out 60ms infinite; }
@media (prefers-reduced-motion: reduce) {
.ae-link:hover .ae-link__shaft,
.ae-link.is-demo .ae-link__shaft {
animation: none !important;
transition: none !important;
transform: scaleX(1) !important;
}
.ae-link:hover .ae-link__head,
.ae-link.is-demo .ae-link__head {
animation: none !important;
transition: none !important;
transform: translateX(0) rotate(45deg) !important;
}
.ae-link__trail,
.ae-link:hover .ae-link__trail,
.ae-link.is-demo .ae-link__trail {
animation: none !important;
opacity: 0 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ae-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff7e6;
text-decoration: none;
font: 600 16px/1 -apple-system, "Segoe UI", sans-serif;
}
.ae-link__arrow {
position: relative;
display: inline-flex;
align-items: center;
width: 26px;
height: 12px;
}
.ae-link__shaft {
position: absolute;
left: 0;
width: 16px;
height: 2px;
background: #fff7e6;
transform-origin: left center;
transform: scaleX(1);
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ae-link__head {
position: absolute;
left: 10px;
width: 8px;
height: 8px;
border-top: 2px solid #fff7e6;
border-right: 2px solid #fff7e6;
transform: translateX(0) rotate(45deg);
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ae-link__trail {
position: absolute;
left: 10px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #fff7e6;
opacity: 0;
}
.ae-link:hover .ae-link__shaft {
transform: scaleX(1.8);
}
.ae-link:hover .ae-link__head {
transform: translateX(10px) rotate(45deg);
}
@keyframes ae-trail-hover {
0% {
opacity: 0;
transform: translateX(0) scale(0.6);
}
45% {
opacity: 0.5;
transform: translateX(5px) scale(1);
}
100% {
opacity: 0;
transform: translateX(9px) scale(0.4);
}
}
.ae-link:hover .ae-link__trail--1 {
animation: ae-trail-hover 420ms ease-out forwards;
}
.ae-link:hover .ae-link__trail--2 {
animation: ae-trail-hover 420ms ease-out 70ms forwards;
}
@keyframes ae-extend-shaft {
0%, 20% {
transform: scaleX(1);
}
50%, 70% {
transform: scaleX(1.8);
}
100% {
transform: scaleX(1);
}
}
@keyframes ae-extend-head {
0%, 20% {
transform: translateX(0) rotate(45deg);
}
50%, 70% {
transform: translateX(10px) rotate(45deg);
}
100% {
transform: translateX(0) rotate(45deg);
}
}
@keyframes ae-trail-loop {
0%, 20% {
opacity: 0;
transform: translateX(0) scale(0.6);
}
38% {
opacity: 0.5;
transform: translateX(5px) scale(1);
}
50%, 70% {
opacity: 0;
transform: translateX(9px) scale(0.4);
}
100% {
opacity: 0;
transform: translateX(0) scale(0.6);
}
}
.ae-link.is-demo .ae-link__shaft {
animation: ae-extend-shaft 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.ae-link.is-demo .ae-link__head {
animation: ae-extend-head 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.ae-link.is-demo .ae-link__trail--1 {
animation: ae-trail-loop 2s ease-out infinite;
}
.ae-link.is-demo .ae-link__trail--2 {
animation: ae-trail-loop 2s ease-out 60ms infinite;
}
@media (prefers-reduced-motion: reduce) {
.ae-link:hover .ae-link__shaft,
.ae-link.is-demo .ae-link__shaft {
animation: none !important;
transition: none !important;
transform: scaleX(1) !important;
}
.ae-link:hover .ae-link__head,
.ae-link.is-demo .ae-link__head {
animation: none !important;
transition: none !important;
transform: translateX(0) rotate(45deg) !important;
}
.ae-link__trail,
.ae-link:hover .ae-link__trail,
.ae-link.is-demo .ae-link__trail {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./ExtendingArrow.module.scss";
export interface ExtendingArrowProps {
duration?: string;
easing?: string;
color?: string;
}
// 실사용: :hover 만으로 동작한다. is-demo 강제 루프는 넣지 않는다 (블로그공장 react 컨벤션)
export default function ExtendingArrow({
duration = "260ms",
easing = "cubic-bezier(.34, 1.56, .64, 1)",
color = "#fff7e6",
}: ExtendingArrowProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<a className={styles.link} href="#" style={style}>
<span>Learn more</span>
<span className={styles.arrow}>
<span className={styles.shaft} />
<span className={styles.head} />
<span className={`${styles.trail} ${styles.trail1}`} aria-hidden="true" />
<span className={`${styles.trail} ${styles.trail2}`} aria-hidden="true" />
</span>
</a>
);
}01 · 길게 늘어나는 화살표 — 측정 누적면적 0.163% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
몸통은 transform: scaleX, 화살촉은 transform: translateX로 각각 움직이되 같은 시간 동안 함께 전환됩니다.
.ae-link__shaft {
transform-origin: left center;
transform: scaleX(1);
transition: transform 220ms;
}
.ae-link__head {
transform: translateX(0) rotate(45deg);
transition: transform 220ms;
}
.ae-link:hover .ae-link__shaft { transform: scaleX(1.8); }
.ae-link:hover .ae-link__head { transform: translateX(10px) rotate(45deg); }
02계속 흐르는 화살표
어디에 쓰나
셰브론(>) 세 개가 짧은 시차를 두고 오른쪽으로 흐르듯 나타났다 사라지기를 반복해, 마우스를 올리지 않아도 다음 단계가 있다는 걸 알려줍니다. 흐름 구간 26%→38%에 오버슛-정착 스탑을 넣어 살짝 튕기는 느낌을 더했고, 맨 끝에는 더 작고 흐린 트레일 점 하나가 한 박자 늦게 따라 흐릅니다.

<main class="stage">
<div class="sl-row" aria-hidden="true">
<span class="sl-chevron sl-chevron--1"></span>
<span class="sl-chevron sl-chevron--2"></span>
<span class="sl-chevron sl-chevron--3"></span>
<span class="sl-trail"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.sl-row {
display: flex;
align-items: center;
gap: $sp-3;
}
.sl-chevron {
width: 20px;
height: 20px;
border-top: 4px solid $color;
border-right: 4px solid $color;
transform: rotate(45deg) translateX(-8px);
opacity: 0;
}
// 실사용·자동재생 공통: 세 개가 짧은 시차를 두고 같은 방향으로 흐른다. 26%에서 살짝 오버슛했다 38%에서 정착한다(고무줄 신장)
@keyframes sl-flow {
0% { opacity: 0; transform: rotate(45deg) translateX(-8px); }
26% { opacity: 1; transform: rotate(45deg) translateX(3px); }
38% { opacity: 1; transform: rotate(45deg) translateX(0); }
70% { opacity: 1; transform: rotate(45deg) translateX(0); }
100% { opacity: 0; transform: rotate(45deg) translateX(8px); }
}
.sl-chevron { animation: sl-flow $duration $easing infinite; }
.sl-chevron--1 { animation-delay: 0ms; }
.sl-chevron--2 { animation-delay: 180ms; }
.sl-chevron--3 { animation-delay: 360ms; }
// 트레일 파티클 — 맨 앞 화살표 뒤로 작은 점 하나가 더 늦게, 더 흐리게 따라간다
.sl-trail {
width: 6px;
height: 6px;
border-radius: 50%;
background: $color;
opacity: 0;
}
@keyframes sl-trail-flow {
0% { opacity: 0; transform: translateX(-8px) scale(.5); }
26% { opacity: .35; transform: translateX(2px) scale(1); }
38% { opacity: .2; transform: translateX(0) scale(.8); }
70% { opacity: .2; transform: translateX(0) scale(.8); }
100% { opacity: 0; transform: translateX(8px) scale(.4); }
}
.sl-trail { animation: sl-trail-flow $duration $easing 540ms infinite; }
@media (prefers-reduced-motion: reduce) {
.sl-chevron {
animation: none !important;
opacity: .6 !important;
transform: rotate(45deg) translateX(0) !important;
}
.sl-trail { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sl-row {
display: flex;
align-items: center;
gap: 12px;
}
.sl-chevron {
width: 20px;
height: 20px;
border-top: 4px solid #17141a;
border-right: 4px solid #17141a;
transform: rotate(45deg) translateX(-8px);
opacity: 0;
}
@keyframes sl-flow {
0% {
opacity: 0;
transform: rotate(45deg) translateX(-8px);
}
26% {
opacity: 1;
transform: rotate(45deg) translateX(3px);
}
38% {
opacity: 1;
transform: rotate(45deg) translateX(0);
}
70% {
opacity: 1;
transform: rotate(45deg) translateX(0);
}
100% {
opacity: 0;
transform: rotate(45deg) translateX(8px);
}
}
.sl-chevron {
animation: sl-flow 1.6s ease-out infinite;
}
.sl-chevron--1 {
animation-delay: 0ms;
}
.sl-chevron--2 {
animation-delay: 180ms;
}
.sl-chevron--3 {
animation-delay: 360ms;
}
.sl-trail {
width: 6px;
height: 6px;
border-radius: 50%;
background: #17141a;
opacity: 0;
}
@keyframes sl-trail-flow {
0% {
opacity: 0;
transform: translateX(-8px) scale(0.5);
}
26% {
opacity: 0.35;
transform: translateX(2px) scale(1);
}
38% {
opacity: 0.2;
transform: translateX(0) scale(0.8);
}
70% {
opacity: 0.2;
transform: translateX(0) scale(0.8);
}
100% {
opacity: 0;
transform: translateX(8px) scale(0.4);
}
}
.sl-trail {
animation: sl-trail-flow 1.6s ease-out 540ms infinite;
}
@media (prefers-reduced-motion: reduce) {
.sl-chevron {
animation: none !important;
opacity: 0.6 !important;
transform: rotate(45deg) translateX(0) !important;
}
.sl-trail {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./LoopingSlideArrow.module.scss";
export interface LoopingSlideArrowProps {
duration?: string;
easing?: string;
color?: string;
}
export default function LoopingSlideArrow({
duration = "1.6s",
easing = "ease-out",
color = "#17141a",
}: LoopingSlideArrowProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.row} style={style} aria-hidden="true">
<span className={`${styles.chevron} ${styles.c1}`} />
<span className={`${styles.chevron} ${styles.c2}`} />
<span className={`${styles.chevron} ${styles.c3}`} />
<span className={styles.trail} />
</div>
);
}02 · 계속 흐르는 화살표 — 측정 누적면적 1.146% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
셋 다 같은 애니메이션을 쓰고 animation-delay만 달라 물이 흐르는 것처럼 보입니다.
@keyframes sl-flow {
0% { opacity: 0; transform: translateX(-8px); }
30%, 70% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(8px); }
}
.sl-chevron { animation: sl-flow 1.6s infinite; }
.sl-chevron--2 { animation-delay: 180ms; }
.sl-chevron--3 { animation-delay: 360ms; }
03아래로 바운스
눈에 보이는 것
아래를 가리키는 화살표 하나가 위아래로 통통 튀며 스크롤해서 더 보라고 부드럽게 재촉합니다. 바운스 정점(42%→50%)에 오버슛-정착을 넣어 튀는 느낌을 강조했고, 화살표 위로 작은 잔상 하나가 늦게 따라 내려옵니다.

<main class="stage">
<span class="bd-wrap" aria-hidden="true">
<span class="bd-chevron"></span>
<span class="bd-trail"></span>
</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.4s;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.bd-wrap {
position: relative;
display: inline-block;
width: 28px;
height: 40px;
}
.bd-chevron {
position: absolute;
top: 8px;
left: 0;
display: block;
width: 28px;
height: 28px;
border-bottom: 4px solid $color;
border-right: 4px solid $color;
transform: rotate(45deg) translateY(0);
}
// 자동 재생: 아래를 가리키는 화살표가 42%에서 오버슛했다 50%에서 정착하며 튄다(고무줄 신장). translateY 만 움직인다
@keyframes bd-bounce {
0%, 100% { transform: rotate(45deg) translateY(0); }
42% { transform: rotate(45deg) translateY(15px); }
50% { transform: rotate(45deg) translateY(12px); }
}
.bd-chevron { animation: bd-bounce $duration $easing infinite; }
// 트레일 파티클 — 화살표 위로 작은 잔상이 살짝 늦게 따라 내려온다
.bd-trail {
position: absolute;
top: 4px;
left: 7px;
display: block;
width: 14px;
height: 14px;
border-bottom: 3px solid $color;
border-right: 3px solid $color;
opacity: 0;
}
@keyframes bd-trail {
0%, 100% { opacity: 0; transform: rotate(45deg) translateY(-4px) scale(.6); }
50% { opacity: .3; transform: rotate(45deg) translateY(6px) scale(.8); }
}
.bd-trail { animation: bd-trail $duration $easing 90ms infinite; }
@media (prefers-reduced-motion: reduce) {
.bd-chevron { animation: none !important; transform: rotate(45deg) translateY(0) !important; }
.bd-trail { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bd-wrap {
position: relative;
display: inline-block;
width: 28px;
height: 40px;
}
.bd-chevron {
position: absolute;
top: 8px;
left: 0;
display: block;
width: 28px;
height: 28px;
border-bottom: 4px solid #fff7e6;
border-right: 4px solid #fff7e6;
transform: rotate(45deg) translateY(0);
}
@keyframes bd-bounce {
0%, 100% {
transform: rotate(45deg) translateY(0);
}
42% {
transform: rotate(45deg) translateY(15px);
}
50% {
transform: rotate(45deg) translateY(12px);
}
}
.bd-chevron {
animation: bd-bounce 1.4s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.bd-trail {
position: absolute;
top: 4px;
left: 7px;
display: block;
width: 14px;
height: 14px;
border-bottom: 3px solid #fff7e6;
border-right: 3px solid #fff7e6;
opacity: 0;
}
@keyframes bd-trail {
0%, 100% {
opacity: 0;
transform: rotate(45deg) translateY(-4px) scale(0.6);
}
50% {
opacity: 0.3;
transform: rotate(45deg) translateY(6px) scale(0.8);
}
}
.bd-trail {
animation: bd-trail 1.4s cubic-bezier(0.2, 1.4, 0.4, 1) 90ms infinite;
}
@media (prefers-reduced-motion: reduce) {
.bd-chevron {
animation: none !important;
transform: rotate(45deg) translateY(0) !important;
}
.bd-trail {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./BounceDownward.module.scss";
export interface BounceDownwardProps {
duration?: string;
easing?: string;
color?: string;
}
export default function BounceDownward({
duration = "1.4s",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#fff7e6",
}: BounceDownwardProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<span className={styles.wrap} style={style} aria-hidden="true">
<span className={styles.chevron} />
<span className={styles.trail} />
</span>
);
}03 · 아래로 바운스 — 측정 누적면적 0.637% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
translateY 값만 오갈 뿐 화살표 모양 자체는 border로 그린 L자를 45도 돌린 것뿐이라 가볍습니다.
.bd-chevron {
border-bottom: 4px solid;
border-right: 4px solid;
transform: rotate(45deg) translateY(0);
}
@keyframes bd-bounce {
0%, 100% { transform: rotate(45deg) translateY(0); }
50% { transform: rotate(45deg) translateY(12px); }
}
.bd-chevron { animation: bd-bounce 1.4s infinite; }
04원+화살표 호버
css 화살표 버튼 만들기에서 어디에 쓰나
원형 버튼에 마우스를 올리면 화살표가 왼쪽에서 오른쪽으로 슬며시 자리를 바꾸며 버튼을 누르라고 안내합니다. 버튼 스케일과 두 화살표가 교차하는 전환을 고무줄식 오버슛 이징으로 바꿔 살짝 커졌다 정착하게 했고, 교차하는 화살표 각각의 뒤로 작은 트레일 점이 붙어 따라옵니다.

<main class="stage">
<a class="cx-btn is-demo" href="#" aria-label="Next">
<span class="cx-btn__arrow cx-btn__arrow--a"></span>
<span class="cx-btn__arrow cx-btn__arrow--b"></span>
<span class="cx-btn__trail cx-btn__trail--a" aria-hidden="true"></span>
<span class="cx-btn__trail cx-btn__trail--b" aria-hidden="true"></span>
</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // is-demo 강제 루프 주기. 실제 호버 전환은 아래 280ms 를 쓴다
$easing: $ease-elastic; // 고무줄식 오버슛 — 커졌다 살짝 되돌아와 멈춘다
$color: $subject-blue;
@include stage($stage-paper);
.cx-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid $subject-ink;
text-decoration: none;
transform: scale(1);
transition: transform 280ms $easing, border-color 280ms $easing;
}
.cx-btn:hover { transform: scale(1.06); border-color: $color; }
.cx-btn__arrow {
position: absolute;
width: 16px;
height: 16px;
border-top: 2px solid $color;
border-right: 2px solid $color;
transform: rotate(45deg);
}
// 두 화살표가 겹쳐 있다가 호버 시 한쪽은 오른쪽으로 빠지고 다른 한쪽이 왼쪽에서 들어와 자리를 대신한다
.cx-btn__arrow--a { opacity: 1; transform: rotate(45deg) translateX(0); transition: transform 280ms $easing, opacity 280ms $easing; }
.cx-btn__arrow--b { opacity: 0; transform: rotate(45deg) translateX(-16px); transition: transform 280ms $easing, opacity 280ms $easing; }
.cx-btn:hover .cx-btn__arrow--a { opacity: 0; transform: rotate(45deg) translateX(16px); }
.cx-btn:hover .cx-btn__arrow--b { opacity: 1; transform: rotate(45deg) translateX(0); }
// 트레일 파티클 — 교차하는 두 화살표 각각 뒤로 작은 점이 지연·감쇠하며 따라붙는다
.cx-btn__trail {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: $color;
opacity: 0;
}
@keyframes cx-trail-a-hover {
0% { opacity: 0; transform: translateX(0) rotate(45deg); }
50% { opacity: .4; transform: translateX(10px) rotate(45deg); }
100% { opacity: 0; transform: translateX(16px) rotate(45deg); }
}
@keyframes cx-trail-b-hover {
0% { opacity: 0; transform: translateX(-16px) rotate(45deg); }
50% { opacity: .4; transform: translateX(-4px) rotate(45deg); }
100% { opacity: 0; transform: translateX(0) rotate(45deg); }
}
.cx-btn:hover .cx-btn__trail--a { animation: cx-trail-a-hover 280ms $ease-out forwards; }
.cx-btn:hover .cx-btn__trail--b { animation: cx-trail-b-hover 280ms $ease-out forwards; }
// 데모 루프: is-demo 는 :hover 를 흉내 낸 keyframes 로 무한 반복 (헤드리스는 :hover 를 못 건다)
@keyframes cx-btn-scale {
0%, 20% { transform: scale(1); border-color: $subject-ink; }
50%, 70% { transform: scale(1.06); border-color: $color; }
100% { transform: scale(1); border-color: $subject-ink; }
}
@keyframes cx-arrow-a {
0%, 20% { opacity: 1; transform: rotate(45deg) translateX(0); }
50%, 70% { opacity: 0; transform: rotate(45deg) translateX(16px); }
100% { opacity: 1; transform: rotate(45deg) translateX(0); }
}
@keyframes cx-arrow-b {
0%, 20% { opacity: 0; transform: rotate(45deg) translateX(-16px); }
50%, 70% { opacity: 1; transform: rotate(45deg) translateX(0); }
100% { opacity: 0; transform: rotate(45deg) translateX(-16px); }
}
@keyframes cx-trail-a-loop {
0%, 20% { opacity: 0; transform: translateX(0) rotate(45deg); }
40% { opacity: .4; transform: translateX(10px) rotate(45deg); }
50%, 70% { opacity: 0; transform: translateX(16px) rotate(45deg); }
100% { opacity: 0; transform: translateX(0) rotate(45deg); }
}
@keyframes cx-trail-b-loop {
0%, 20% { opacity: 0; transform: translateX(-16px) rotate(45deg); }
55% { opacity: .4; transform: translateX(-4px) rotate(45deg); }
70%, 100% { opacity: 0; transform: translateX(0) rotate(45deg); }
}
.cx-btn.is-demo { animation: cx-btn-scale $duration $easing infinite; }
.cx-btn.is-demo .cx-btn__arrow--a { animation: cx-arrow-a $duration $easing infinite; }
.cx-btn.is-demo .cx-btn__arrow--b { animation: cx-arrow-b $duration $easing infinite; }
.cx-btn.is-demo .cx-btn__trail--a { animation: cx-trail-a-loop $duration $ease-out infinite; }
.cx-btn.is-demo .cx-btn__trail--b { animation: cx-trail-b-loop $duration $ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cx-btn,
.cx-btn.is-demo,
.cx-btn__arrow--a,
.cx-btn__arrow--b,
.cx-btn.is-demo .cx-btn__arrow--a,
.cx-btn.is-demo .cx-btn__arrow--b {
animation: none !important;
transition: none !important;
}
.cx-btn { transform: scale(1) !important; border-color: $subject-ink !important; }
.cx-btn__arrow--a { opacity: 1 !important; transform: rotate(45deg) translateX(0) !important; }
.cx-btn__arrow--b { opacity: 0 !important; transform: rotate(45deg) translateX(-16px) !important; }
.cx-btn__trail,
.cx-btn:hover .cx-btn__trail,
.cx-btn.is-demo .cx-btn__trail {
animation: none !important;
opacity: 0 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cx-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid #17141a;
text-decoration: none;
transform: scale(1);
transition: transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1), border-color 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cx-btn:hover {
transform: scale(1.06);
border-color: #2f6df6;
}
.cx-btn__arrow {
position: absolute;
width: 16px;
height: 16px;
border-top: 2px solid #2f6df6;
border-right: 2px solid #2f6df6;
transform: rotate(45deg);
}
.cx-btn__arrow--a {
opacity: 1;
transform: rotate(45deg) translateX(0);
transition: transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cx-btn__arrow--b {
opacity: 0;
transform: rotate(45deg) translateX(-16px);
transition: transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cx-btn:hover .cx-btn__arrow--a {
opacity: 0;
transform: rotate(45deg) translateX(16px);
}
.cx-btn:hover .cx-btn__arrow--b {
opacity: 1;
transform: rotate(45deg) translateX(0);
}
.cx-btn__trail {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
}
@keyframes cx-trail-a-hover {
0% {
opacity: 0;
transform: translateX(0) rotate(45deg);
}
50% {
opacity: 0.4;
transform: translateX(10px) rotate(45deg);
}
100% {
opacity: 0;
transform: translateX(16px) rotate(45deg);
}
}
@keyframes cx-trail-b-hover {
0% {
opacity: 0;
transform: translateX(-16px) rotate(45deg);
}
50% {
opacity: 0.4;
transform: translateX(-4px) rotate(45deg);
}
100% {
opacity: 0;
transform: translateX(0) rotate(45deg);
}
}
.cx-btn:hover .cx-btn__trail--a {
animation: cx-trail-a-hover 280ms ease-out forwards;
}
.cx-btn:hover .cx-btn__trail--b {
animation: cx-trail-b-hover 280ms ease-out forwards;
}
@keyframes cx-btn-scale {
0%, 20% {
transform: scale(1);
border-color: #17141a;
}
50%, 70% {
transform: scale(1.06);
border-color: #2f6df6;
}
100% {
transform: scale(1);
border-color: #17141a;
}
}
@keyframes cx-arrow-a {
0%, 20% {
opacity: 1;
transform: rotate(45deg) translateX(0);
}
50%, 70% {
opacity: 0;
transform: rotate(45deg) translateX(16px);
}
100% {
opacity: 1;
transform: rotate(45deg) translateX(0);
}
}
@keyframes cx-arrow-b {
0%, 20% {
opacity: 0;
transform: rotate(45deg) translateX(-16px);
}
50%, 70% {
opacity: 1;
transform: rotate(45deg) translateX(0);
}
100% {
opacity: 0;
transform: rotate(45deg) translateX(-16px);
}
}
@keyframes cx-trail-a-loop {
0%, 20% {
opacity: 0;
transform: translateX(0) rotate(45deg);
}
40% {
opacity: 0.4;
transform: translateX(10px) rotate(45deg);
}
50%, 70% {
opacity: 0;
transform: translateX(16px) rotate(45deg);
}
100% {
opacity: 0;
transform: translateX(0) rotate(45deg);
}
}
@keyframes cx-trail-b-loop {
0%, 20% {
opacity: 0;
transform: translateX(-16px) rotate(45deg);
}
55% {
opacity: 0.4;
transform: translateX(-4px) rotate(45deg);
}
70%, 100% {
opacity: 0;
transform: translateX(0) rotate(45deg);
}
}
.cx-btn.is-demo {
animation: cx-btn-scale 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.cx-btn.is-demo .cx-btn__arrow--a {
animation: cx-arrow-a 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.cx-btn.is-demo .cx-btn__arrow--b {
animation: cx-arrow-b 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.cx-btn.is-demo .cx-btn__trail--a {
animation: cx-trail-a-loop 2s ease-out infinite;
}
.cx-btn.is-demo .cx-btn__trail--b {
animation: cx-trail-b-loop 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.cx-btn,
.cx-btn.is-demo,
.cx-btn__arrow--a,
.cx-btn__arrow--b,
.cx-btn.is-demo .cx-btn__arrow--a,
.cx-btn.is-demo .cx-btn__arrow--b {
animation: none !important;
transition: none !important;
}
.cx-btn {
transform: scale(1) !important;
border-color: #17141a !important;
}
.cx-btn__arrow--a {
opacity: 1 !important;
transform: rotate(45deg) translateX(0) !important;
}
.cx-btn__arrow--b {
opacity: 0 !important;
transform: rotate(45deg) translateX(-16px) !important;
}
.cx-btn__trail,
.cx-btn:hover .cx-btn__trail,
.cx-btn.is-demo .cx-btn__trail {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./CircleArrowHover.module.scss";
export interface CircleArrowHoverProps {
duration?: string;
easing?: string;
color?: string;
}
export default function CircleArrowHover({
duration = "280ms",
easing = "cubic-bezier(.34, 1.56, .64, 1)",
color = "#2f6df6",
}: CircleArrowHoverProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<a className={styles.btn} href="#" aria-label="Next" style={style}>
<span className={`${styles.arrow} ${styles.a}`} />
<span className={`${styles.arrow} ${styles.b}`} />
<span className={`${styles.trail} ${styles.trailA}`} aria-hidden="true" />
<span className={`${styles.trail} ${styles.trailB}`} aria-hidden="true" />
</a>
);
}04 · 원+화살표 호버 — 측정 누적면적 1.206% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
실제로는 화살표 두 장이 겹쳐 있다가 하나는 오른쪽으로 빠지며 사라지고 다른 하나가 왼쪽에서 들어와 대신합니다.
.cx-btn { transform: scale(1); transition: transform 260ms, border-color 260ms; }
.cx-btn__arrow--a { opacity: 1; transform: translateX(0); }
.cx-btn__arrow--b { opacity: 0; transform: translateX(-16px); }
.cx-btn:hover .cx-btn__arrow--a { opacity: 0; transform: translateX(16px); }
.cx-btn:hover .cx-btn__arrow--b { opacity: 1; transform: translateX(0); }
.cx-btn:hover { transform: scale(1.06); border-color: #2f6df6; }
05외부 링크 45도 회전
무슨 일이 일어나나
외부 링크 옆 화살표에 마우스를 올리면 화살표가 정확히 45도 더 돌아가며 살짝 빠져나가듯 움직여, 이 링크가 새 창에서 열린다는 걸 넌지시 알려줍니다. 회전 전환을 고무줄식 오버슛 이징으로 바꿔 45도를 살짝 넘었다가 정착하도록 했고, 회전하는 화살표 뒤로 흐린 잔상 하나가 따라붙습니다.

<main class="stage">
<a class="er-link is-demo" href="#" target="_blank" rel="noopener">
<span class="er-link__label">example.com</span>
<span class="er-link__arrow">
<span class="er-link__shape"></span>
<span class="er-link__trail" aria-hidden="true"></span>
</span>
</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // is-demo 강제 루프 주기. 실제 호버 전환은 아래 260ms 를 쓴다
$easing: $ease-elastic; // 고무줄식 오버슛 — 회전이 45도를 살짝 넘었다 되돌아와 멈춘다
$color: $subject-cream;
@include stage($stage-ink);
.er-link {
display: inline-flex;
align-items: center;
gap: $sp-2;
color: $color;
text-decoration: none;
font: 600 16px/1 -apple-system, "Segoe UI", sans-serif;
}
.er-link__arrow {
position: relative;
display: inline-block;
width: 12px;
height: 12px;
}
.er-link__shape {
position: absolute;
inset: 0;
border-top: 2px solid $color;
border-right: 2px solid $color;
transform: rotate(0deg) translate(0, 0);
transition: transform 260ms $easing;
}
// 트레일 파티클 — 회전하는 화살표 뒤로 흐린 잔상 하나가 따라붙는다
.er-link__trail {
position: absolute;
inset: 0;
border-top: 2px solid $color;
border-right: 2px solid $color;
opacity: 0;
}
// 실사용: 링크에 마우스를 올리면 화살표가 45도 더 돌아가며 밖으로 살짝 빠져나가듯 움직여 "새 창에서 열림"을 암시한다
.er-link:hover .er-link__shape { transform: rotate(45deg) translate(2px, -2px); }
@keyframes er-trail-hover {
0% { opacity: 0; transform: rotate(0deg) translate(0, 0) scale(.7); }
45% { opacity: .4; transform: rotate(30deg) translate(1px, -1px) scale(1); }
100% { opacity: 0; transform: rotate(45deg) translate(2px, -2px) scale(.5); }
}
.er-link:hover .er-link__trail { animation: er-trail-hover 300ms $ease-out forwards; }
// 데모 루프: is-demo 는 :hover 를 흉내 낸 keyframes 로 무한 반복 (헤드리스는 :hover 를 못 건다)
@keyframes er-rotate {
0%, 20% { transform: rotate(0deg) translate(0, 0); }
50%, 70% { transform: rotate(45deg) translate(2px, -2px); }
100% { transform: rotate(0deg) translate(0, 0); }
}
@keyframes er-trail-loop {
0%, 20% { opacity: 0; transform: rotate(0deg) translate(0, 0) scale(.7); }
40% { opacity: .4; transform: rotate(30deg) translate(1px, -1px) scale(1); }
50%, 70% { opacity: 0; transform: rotate(45deg) translate(2px, -2px) scale(.5); }
100% { opacity: 0; transform: rotate(0deg) translate(0, 0) scale(.7); }
}
.er-link.is-demo .er-link__shape { animation: er-rotate $duration $easing infinite; }
.er-link.is-demo .er-link__trail { animation: er-trail-loop $duration $ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.er-link:hover .er-link__shape,
.er-link.is-demo .er-link__shape {
animation: none !important;
transition: none !important;
transform: rotate(0deg) translate(0, 0) !important;
}
.er-link__trail,
.er-link:hover .er-link__trail,
.er-link.is-demo .er-link__trail {
animation: none !important;
opacity: 0 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.er-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff7e6;
text-decoration: none;
font: 600 16px/1 -apple-system, "Segoe UI", sans-serif;
}
.er-link__arrow {
position: relative;
display: inline-block;
width: 12px;
height: 12px;
}
.er-link__shape {
position: absolute;
inset: 0;
border-top: 2px solid #fff7e6;
border-right: 2px solid #fff7e6;
transform: rotate(0deg) translate(0, 0);
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.er-link__trail {
position: absolute;
inset: 0;
border-top: 2px solid #fff7e6;
border-right: 2px solid #fff7e6;
opacity: 0;
}
.er-link:hover .er-link__shape {
transform: rotate(45deg) translate(2px, -2px);
}
@keyframes er-trail-hover {
0% {
opacity: 0;
transform: rotate(0deg) translate(0, 0) scale(0.7);
}
45% {
opacity: 0.4;
transform: rotate(30deg) translate(1px, -1px) scale(1);
}
100% {
opacity: 0;
transform: rotate(45deg) translate(2px, -2px) scale(0.5);
}
}
.er-link:hover .er-link__trail {
animation: er-trail-hover 300ms ease-out forwards;
}
@keyframes er-rotate {
0%, 20% {
transform: rotate(0deg) translate(0, 0);
}
50%, 70% {
transform: rotate(45deg) translate(2px, -2px);
}
100% {
transform: rotate(0deg) translate(0, 0);
}
}
@keyframes er-trail-loop {
0%, 20% {
opacity: 0;
transform: rotate(0deg) translate(0, 0) scale(0.7);
}
40% {
opacity: 0.4;
transform: rotate(30deg) translate(1px, -1px) scale(1);
}
50%, 70% {
opacity: 0;
transform: rotate(45deg) translate(2px, -2px) scale(0.5);
}
100% {
opacity: 0;
transform: rotate(0deg) translate(0, 0) scale(0.7);
}
}
.er-link.is-demo .er-link__shape {
animation: er-rotate 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.er-link.is-demo .er-link__trail {
animation: er-trail-loop 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.er-link:hover .er-link__shape,
.er-link.is-demo .er-link__shape {
animation: none !important;
transition: none !important;
transform: rotate(0deg) translate(0, 0) !important;
}
.er-link__trail,
.er-link:hover .er-link__trail,
.er-link.is-demo .er-link__trail {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./ExternalLinkRotate.module.scss";
export interface ExternalLinkRotateProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ExternalLinkRotate({
duration = "260ms",
easing = "cubic-bezier(.34, 1.56, .64, 1)",
color = "#fff7e6",
}: ExternalLinkRotateProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<a className={styles.link} href="#" target="_blank" rel="noopener" style={style}>
<span>example.com</span>
<span className={styles.arrow}>
<span className={styles.shape} />
<span className={styles.trail} aria-hidden="true" />
</span>
</a>
);
}05 · 외부 링크 45도 회전 — 측정 누적면적 0.103% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
.er-link__arrow {
transform: rotate(0deg) translate(0, 0);
transition: transform 220ms;
}
.er-link:hover .er-link__arrow {
transform: rotate(45deg) translate(2px, -2px);
}
06선이 그려지는 화살표
화면에서 벌어지는 일
화살표 모양 SVG 선이 처음부터 끝까지 손으로 그은 듯 나타났다가 다시 지워지기를 반복합니다. 선이 다 그려지는 순간(58%→66%)에는 화살촉 자리에 작은 원이 고무줄처럼 커졌다가 정착하는 오버슛 팝이 더해지고, 90ms 지연된 흐린 트레일 패스(사본)가 한 박자 늦게 뒤따릅니다.

<main class="stage">
<svg class="ds-svg" viewBox="0 0 100 60" aria-hidden="true">
<path class="ds-path-trail" pathLength="100" d="M10,30 L80,30 L58,10 M80,30 L58,50" />
<path class="ds-path" pathLength="100" d="M10,30 L80,30 L58,10 M80,30 L58,50" />
<circle class="ds-tip" cx="80" cy="30" r="3" />
</svg>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2.2s;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ds-svg {
width: min(220px, 70vw);
height: auto;
overflow: visible;
}
.ds-path {
fill: none;
stroke: $color;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100; // pathLength="100" 이라 %처럼 다룬다
animation: ds-draw $duration $easing infinite;
}
// 트레일 파티클 — 흐린 사본이 살짝 지연되어 뒤따라 그려진다
.ds-path-trail {
fill: none;
stroke: $color;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
opacity: .22;
animation: ds-draw $duration $easing 90ms infinite;
}
// 화살촉 오버슛 — 다 그려지는 순간(58%) 원이 고무줄처럼 커졌다 66%에서 정착한다
.ds-tip {
fill: $color;
opacity: 0;
transform-origin: 80px 30px;
transform: scale(0);
}
@keyframes ds-tip-pop {
0%, 50% { opacity: 0; transform: scale(0); }
58% { opacity: 1; transform: scale(1.6); }
66% { opacity: 1; transform: scale(1); }
78%, 100% { opacity: 0; transform: scale(0); }
}
.ds-tip { animation: ds-tip-pop $duration $easing infinite; }
// 자동 재생: 화살표가 처음부터 끝까지 그려졌다가 잠깐 멈춘 뒤 반대쪽으로 지워지며 한 바퀴 돈다
@keyframes ds-draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
@media (prefers-reduced-motion: reduce) {
.ds-path { animation: none !important; stroke-dashoffset: 0 !important; }
.ds-path-trail { animation: none !important; opacity: 0 !important; }
.ds-tip { animation: none !important; opacity: 0 !important; transform: scale(0) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ds-svg {
width: min(220px, 70vw);
height: auto;
overflow: visible;
}
.ds-path {
fill: none;
stroke: #17141a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
animation: ds-draw 2.2s ease-out infinite;
}
.ds-path-trail {
fill: none;
stroke: #17141a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
opacity: 0.22;
animation: ds-draw 2.2s ease-out 90ms infinite;
}
.ds-tip {
fill: #17141a;
opacity: 0;
transform-origin: 80px 30px;
transform: scale(0);
}
@keyframes ds-tip-pop {
0%, 50% {
opacity: 0;
transform: scale(0);
}
58% {
opacity: 1;
transform: scale(1.6);
}
66% {
opacity: 1;
transform: scale(1);
}
78%, 100% {
opacity: 0;
transform: scale(0);
}
}
.ds-tip {
animation: ds-tip-pop 2.2s ease-out infinite;
}
@keyframes ds-draw {
0% {
stroke-dashoffset: 100;
}
55% {
stroke-dashoffset: 0;
}
80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -100;
}
}
@media (prefers-reduced-motion: reduce) {
.ds-path {
animation: none !important;
stroke-dashoffset: 0 !important;
}
.ds-path-trail {
animation: none !important;
opacity: 0 !important;
}
.ds-tip {
animation: none !important;
opacity: 0 !important;
transform: scale(0) !important;
}
}import { CSSProperties } from "react";
import styles from "./SvgDrawnArrow.module.scss";
export interface SvgDrawnArrowProps {
duration?: string;
easing?: string;
color?: string;
}
export default function SvgDrawnArrow({
duration = "2.2s",
easing = "ease-out",
color = "#17141a",
}: SvgDrawnArrowProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<svg className={styles.svg} viewBox="0 0 100 60" aria-hidden="true" style={style}>
<path className={styles.pathTrail} pathLength={100} d="M10,30 L80,30 L58,10 M80,30 L58,50" />
<path className={styles.path} pathLength={100} d="M10,30 L80,30 L58,10 M80,30 L58,50" />
<circle className={styles.tip} cx={80} cy={30} r={3} />
</svg>
);
}06 · 선이 그려지는 화살표 — 측정 누적면적 2.082% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
stroke-dashoffset 값을 100에서 0으로 줄이면 선이 그려지고, 다시 -100까지 줄이면 반대쪽부터 지워지는 방식입니다.
.ds-path {
stroke-dasharray: 100;
animation: ds-draw 2.2s infinite;
}
@keyframes ds-draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
07셰브론 웨이브
어떻게 보이나
위아래로 겹쳐 놓은 셰브론 세 개가 순서대로 밝아졌다 흐려지며 물결처럼 흘러갑니다. 펄스 정점(42%→50%)에 오버슛-정착을 넣어 튕기는 느낌을 얹었고, 더 작고 흐린 셰브론 트레일 하나가 450ms 늦게 뒤따라옵니다.

<main class="stage">
<div class="cw-wave" aria-hidden="true">
<span class="cw-chevron cw-chevron--1"></span>
<span class="cw-chevron cw-chevron--2"></span>
<span class="cw-chevron cw-chevron--3"></span>
<span class="cw-chevron cw-chevron--trail"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.5s;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.cw-wave {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
}
.cw-chevron {
width: 22px;
height: 22px;
border-bottom: 3px solid $color;
border-right: 3px solid $color;
transform: rotate(45deg) translateY(0);
opacity: .3;
}
// 자동 재생: 아래로 겹쳐 둔 셰브론 세 개가 순서대로 밝아졌다 흐려지며 물결처럼 흘러간다. 42%에서 오버슛했다 50%에서 정착한다
@keyframes cw-pulse {
0%, 100% { opacity: .3; transform: rotate(45deg) translateY(0); }
42% { opacity: 1; transform: rotate(45deg) translateY(8px); }
50% { opacity: 1; transform: rotate(45deg) translateY(6px); }
}
.cw-chevron { animation: cw-pulse $duration $easing infinite; }
.cw-chevron--1 { animation-delay: 0ms; }
.cw-chevron--2 { animation-delay: 150ms; }
.cw-chevron--3 { animation-delay: 300ms; }
// 트레일 파티클 — 맨 끝에 더 작고 흐린 셰브론 하나가 더 늦게 따라 흐른다
.cw-chevron--trail {
width: 14px;
height: 14px;
opacity: 0;
}
@keyframes cw-trail-pulse {
0%, 100% { opacity: 0; transform: rotate(45deg) translateY(0) scale(.6); }
50% { opacity: .3; transform: rotate(45deg) translateY(10px) scale(.8); }
}
.cw-chevron--trail { animation: cw-trail-pulse $duration $easing 450ms infinite; }
@media (prefers-reduced-motion: reduce) {
.cw-chevron { animation: none !important; opacity: .7 !important; transform: rotate(45deg) translateY(0) !important; }
.cw-chevron--trail { opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cw-wave {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.cw-chevron {
width: 22px;
height: 22px;
border-bottom: 3px solid #fff7e6;
border-right: 3px solid #fff7e6;
transform: rotate(45deg) translateY(0);
opacity: 0.3;
}
@keyframes cw-pulse {
0%, 100% {
opacity: 0.3;
transform: rotate(45deg) translateY(0);
}
42% {
opacity: 1;
transform: rotate(45deg) translateY(8px);
}
50% {
opacity: 1;
transform: rotate(45deg) translateY(6px);
}
}
.cw-chevron {
animation: cw-pulse 1.5s ease-out infinite;
}
.cw-chevron--1 {
animation-delay: 0ms;
}
.cw-chevron--2 {
animation-delay: 150ms;
}
.cw-chevron--3 {
animation-delay: 300ms;
}
.cw-chevron--trail {
width: 14px;
height: 14px;
opacity: 0;
}
@keyframes cw-trail-pulse {
0%, 100% {
opacity: 0;
transform: rotate(45deg) translateY(0) scale(0.6);
}
50% {
opacity: 0.3;
transform: rotate(45deg) translateY(10px) scale(0.8);
}
}
.cw-chevron--trail {
animation: cw-trail-pulse 1.5s ease-out 450ms infinite;
}
@media (prefers-reduced-motion: reduce) {
.cw-chevron {
animation: none !important;
opacity: 0.7 !important;
transform: rotate(45deg) translateY(0) !important;
}
.cw-chevron--trail {
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./ChevronWave.module.scss";
export interface ChevronWaveProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ChevronWave({
duration = "1.5s",
easing = "ease-out",
color = "#fff7e6",
}: ChevronWaveProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.wave} style={style} aria-hidden="true">
<span className={`${styles.chevron} ${styles.c1}`} />
<span className={`${styles.chevron} ${styles.c2}`} />
<span className={`${styles.chevron} ${styles.c3}`} />
<span className={styles.trail} />
</div>
);
}07 · 셰브론 웨이브 — 측정 누적면적 0.931% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
스크롤을 안내하거나 로딩을 기다리는 화면에 장식으로 쓰기 좋습니다.
@keyframes cw-pulse {
0%, 100% { opacity: .3; transform: translateY(0); }
50% { opacity: 1; transform: translateY(6px); }
}
.cw-chevron { animation: cw-pulse 1.5s infinite; }
.cw-chevron--2 { animation-delay: 150ms; }
.cw-chevron--3 { animation-delay: 300ms; }
08마우스 모양 스크롤 안내
언제 쓰나
마우스 테두리 안의 작은 점이 아래로 흘러내리듯 움직이다 사라지기를 반복해 스크롤을 유도합니다. 점이 떨어지는 끝자락(55%→63%→70%)에 오버슛-정착을 넣었고, 점 위로 120ms 지연된 작은 트레일이 흐리게 따라 내려옵니다.

<main class="stage">
<div class="ms-mouse" aria-hidden="true">
<span class="ms-dot"></span>
<span class="ms-trail"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.ms-mouse {
position: relative;
box-sizing: border-box;
width: 44px;
height: 68px;
border: 3px solid $color;
border-radius: $r-pill;
display: flex;
justify-content: center;
padding-top: $sp-3;
}
.ms-dot {
width: 8px;
height: 16px;
border-radius: $r-pill;
background: $color;
opacity: 1;
transform: translateY(0);
}
// 자동 재생: 휠을 나타내는 점이 마우스 테두리 안에서 아래로 흘러내리듯 움직이다 사라지기를 반복한다. 63%에서 오버슛했다 70%에서 정착·소멸한다
@keyframes ms-scroll {
0% { opacity: 1; transform: translateY(0); }
55% { opacity: 1; transform: translateY(20px); }
63% { opacity: .6; transform: translateY(27px); }
70% { opacity: 0; transform: translateY(24px); }
100% { opacity: 0; transform: translateY(24px); }
}
.ms-dot { animation: ms-scroll $duration $easing infinite; }
// 트레일 파티클 — 점 위로 작은 잔상이 더 늦게, 더 흐리게 따라 내려온다
.ms-trail {
position: absolute;
top: $sp-3;
width: 5px;
height: 5px;
border-radius: $r-pill;
background: $color;
opacity: 0;
}
@keyframes ms-trail-scroll {
0% { opacity: 0; transform: translateY(-4px) scale(.6); }
40% { opacity: .3; transform: translateY(8px) scale(.8); }
85%, 100% { opacity: 0; transform: translateY(20px) scale(.4); }
}
.ms-trail { animation: ms-trail-scroll $duration $easing 120ms infinite; }
@media (prefers-reduced-motion: reduce) {
.ms-dot { animation: none !important; opacity: 1 !important; transform: translateY(0) !important; }
.ms-trail { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ms-mouse {
position: relative;
box-sizing: border-box;
width: 44px;
height: 68px;
border: 3px solid #17141a;
border-radius: 999px;
display: flex;
justify-content: center;
padding-top: 12px;
}
.ms-dot {
width: 8px;
height: 16px;
border-radius: 999px;
background: #17141a;
opacity: 1;
transform: translateY(0);
}
@keyframes ms-scroll {
0% {
opacity: 1;
transform: translateY(0);
}
55% {
opacity: 1;
transform: translateY(20px);
}
63% {
opacity: 0.6;
transform: translateY(27px);
}
70% {
opacity: 0;
transform: translateY(24px);
}
100% {
opacity: 0;
transform: translateY(24px);
}
}
.ms-dot {
animation: ms-scroll 1.6s ease-out infinite;
}
.ms-trail {
position: absolute;
top: 12px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #17141a;
opacity: 0;
}
@keyframes ms-trail-scroll {
0% {
opacity: 0;
transform: translateY(-4px) scale(0.6);
}
40% {
opacity: 0.3;
transform: translateY(8px) scale(0.8);
}
85%, 100% {
opacity: 0;
transform: translateY(20px) scale(0.4);
}
}
.ms-trail {
animation: ms-trail-scroll 1.6s ease-out 120ms infinite;
}
@media (prefers-reduced-motion: reduce) {
.ms-dot {
animation: none !important;
opacity: 1 !important;
transform: translateY(0) !important;
}
.ms-trail {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./MouseScrollHint.module.scss";
export interface MouseScrollHintProps {
duration?: string;
easing?: string;
color?: string;
}
export default function MouseScrollHint({
duration = "1.6s",
easing = "ease-out",
color = "#17141a",
}: MouseScrollHintProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.mouse} style={style} aria-hidden="true">
<span className={styles.dot} />
<span className={styles.trail} />
</div>
);
}08 · 마우스 모양 스크롤 안내 — 측정 누적면적 0.236% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
히어로 섹션 맨 아래에 두면 "여기 더 있다"는 신호가 됩니다.
.ms-mouse { border: 3px solid; border-radius: 999px; padding-top: 12px; }
.ms-dot { animation: ms-scroll 1.6s infinite; }
@keyframes ms-scroll {
0% { opacity: 1; transform: translateY(0); }
70%, 100% { opacity: 0; transform: translateY(24px); }
}
09두 화살표 교차
어떤 화면인가
같은 대각선 위의 화살표 두 개가 서로 반대 방향으로 미끄러지며 자리를 맞바꾸듯 교차합니다. 교차 스왑의 정점(46%→50%)에 오버슛-정착을 넣었고, 두 화살표 각각의 뒤로 60ms 지연된 작은 트레일 점이 붙어 따라옵니다.

<main class="stage">
<div class="xs-wrap" aria-hidden="true">
<span class="xs-arrow xs-arrow--a"></span>
<span class="xs-arrow xs-arrow--b"></span>
<span class="xs-trail xs-trail--a"></span>
<span class="xs-trail xs-trail--b"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.8s;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.xs-wrap {
position: relative;
width: 40px;
height: 40px;
}
.xs-arrow {
position: absolute;
top: 16px;
left: 16px;
width: 16px;
height: 16px;
border-top: 3px solid;
border-right: 3px solid;
}
// 두 화살표가 같은 대각선 위에서 서로 반대 방향으로 미끄러지며 자리를 맞바꾸듯 교차한다. 46%에서 오버슛했다 50%에서 정착한다
.xs-arrow--a { border-color: $color; transform: translate(-6px, 6px) rotate(-45deg); }
.xs-arrow--b { border-color: $subject-cream; transform: translate(6px, -6px) rotate(135deg); }
@keyframes xs-swap-a {
0%, 100% { transform: translate(-6px, 6px) rotate(-45deg); }
46% { transform: translate(8px, -8px) rotate(-45deg); }
50% { transform: translate(6px, -6px) rotate(-45deg); }
}
@keyframes xs-swap-b {
0%, 100% { transform: translate(6px, -6px) rotate(135deg); }
46% { transform: translate(-8px, 8px) rotate(135deg); }
50% { transform: translate(-6px, 6px) rotate(135deg); }
}
.xs-arrow--a { animation: xs-swap-a $duration $easing infinite; }
.xs-arrow--b { animation: xs-swap-b $duration $easing infinite; }
// 트레일 파티클 — 교차하는 화살표 각각 뒤로 작은 점이 살짝 지연되어 흐리게 따라붙는다
.xs-trail {
position: absolute;
top: 16px;
left: 16px;
width: 4px;
height: 4px;
border-radius: 50%;
opacity: 0;
}
.xs-trail--a { background: $color; }
.xs-trail--b { background: $subject-cream; }
@keyframes xs-trail-a {
0%, 100% { opacity: 0; transform: translate(-3px, 3px) scale(.6); }
50% { opacity: .35; transform: translate(3px, -3px) scale(1); }
}
@keyframes xs-trail-b {
0%, 100% { opacity: 0; transform: translate(3px, -3px) scale(.6); }
50% { opacity: .35; transform: translate(-3px, 3px) scale(1); }
}
.xs-trail--a { animation: xs-trail-a $duration $easing 60ms infinite; }
.xs-trail--b { animation: xs-trail-b $duration $easing 60ms infinite; }
@media (prefers-reduced-motion: reduce) {
.xs-arrow--a { animation: none !important; transform: translate(-6px, 6px) rotate(-45deg) !important; }
.xs-arrow--b { animation: none !important; transform: translate(6px, -6px) rotate(135deg) !important; }
.xs-trail--a, .xs-trail--b { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.xs-wrap {
position: relative;
width: 40px;
height: 40px;
}
.xs-arrow {
position: absolute;
top: 16px;
left: 16px;
width: 16px;
height: 16px;
border-top: 3px solid;
border-right: 3px solid;
}
.xs-arrow--a {
border-color: #2f6df6;
transform: translate(-6px, 6px) rotate(-45deg);
}
.xs-arrow--b {
border-color: #fff7e6;
transform: translate(6px, -6px) rotate(135deg);
}
@keyframes xs-swap-a {
0%, 100% {
transform: translate(-6px, 6px) rotate(-45deg);
}
46% {
transform: translate(8px, -8px) rotate(-45deg);
}
50% {
transform: translate(6px, -6px) rotate(-45deg);
}
}
@keyframes xs-swap-b {
0%, 100% {
transform: translate(6px, -6px) rotate(135deg);
}
46% {
transform: translate(-8px, 8px) rotate(135deg);
}
50% {
transform: translate(-6px, 6px) rotate(135deg);
}
}
.xs-arrow--a {
animation: xs-swap-a 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.xs-arrow--b {
animation: xs-swap-b 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.xs-trail {
position: absolute;
top: 16px;
left: 16px;
width: 4px;
height: 4px;
border-radius: 50%;
opacity: 0;
}
.xs-trail--a {
background: #2f6df6;
}
.xs-trail--b {
background: #fff7e6;
}
@keyframes xs-trail-a {
0%, 100% {
opacity: 0;
transform: translate(-3px, 3px) scale(0.6);
}
50% {
opacity: 0.35;
transform: translate(3px, -3px) scale(1);
}
}
@keyframes xs-trail-b {
0%, 100% {
opacity: 0;
transform: translate(3px, -3px) scale(0.6);
}
50% {
opacity: 0.35;
transform: translate(-3px, 3px) scale(1);
}
}
.xs-trail--a {
animation: xs-trail-a 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) 60ms infinite;
}
.xs-trail--b {
animation: xs-trail-b 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) 60ms infinite;
}
@media (prefers-reduced-motion: reduce) {
.xs-arrow--a {
animation: none !important;
transform: translate(-6px, 6px) rotate(-45deg) !important;
}
.xs-arrow--b {
animation: none !important;
transform: translate(6px, -6px) rotate(135deg) !important;
}
.xs-trail--a, .xs-trail--b {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./DoubleArrowSwap.module.scss";
export interface DoubleArrowSwapProps {
duration?: string;
easing?: string;
color?: string;
}
export default function DoubleArrowSwap({
duration = "1.8s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: DoubleArrowSwapProps) {
const style = { "--duration": duration, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.wrap} style={style} aria-hidden="true">
<span className={`${styles.arrow} ${styles.a}`} />
<span className={`${styles.arrow} ${styles.b}`} />
<span className={`${styles.trail} ${styles.trailA}`} />
<span className={`${styles.trail} ${styles.trailB}`} />
</div>
);
}09 · 두 화살표 교차 — 측정 누적면적 0.637% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
정렬 순서를 뒤집거나 값을 교환하는 버튼에 붙이면 "바뀐다"는 느낌을 전달합니다.
@keyframes xs-swap-a {
0%, 100% { transform: translate(-6px, 6px) rotate(-45deg); }
50% { transform: translate(6px, -6px) rotate(-45deg); }
}
@keyframes xs-swap-b {
0%, 100% { transform: translate(6px, -6px) rotate(135deg); }
50% { transform: translate(-6px, 6px) rotate(135deg); }
}
CSS 화살표 애니메이션은 어디서 깨지는가
무엇이 문제인가
가장 먼저 걸려 넘어진 건 08번 마우스 스크롤 안내였습니다. 처음엔 점을 4×8px, 이동 거리를 14px로 아주 작게 잡고 렌더링해서 실측했더니 바뀐 픽셀 면적이 0.06%밖에 안 나왔습니다 — 눈에는 분명 움직이는데도 캔버스 전체에서 차지하는 비중이 너무 작아 "안 움직이는 것"과 구분이 안 될 정도였습니다. 점을 8×16px로, 이동 거리를 24px로 키우고 마우스 테두리도 44×68px로 함께 키우고 나서야 0.236%로 넉넉하게 통과했습니다.
어떻게 피하나
아이콘이 배경 무늬에 파묻히는 순간은 점과 물결이 화면을 채우는 멤피스 패턴 랜딩에서도 쉽게 와서, 무늬 쪽을 쉬게 만드는 규칙으로 막습니다.
화살표처럼 원래 작은 도형은 크기를 넉넉히 잡아야 움직임 자체가 전달됩니다. 01번 늘어나는 화살표도 비슷한 이유로 여유가 크지 않은데, transform-origin: left center를 빼먹으면 몸통이 가운데를 기준으로 늘어나 화살촉과 어긋나 버리니 주의가 필요합니다. 이 아홉 개를 담은 압축 파일을 여는 열쇠는 8dggvep3이라는 자리에 숨어 있는데, 상자나 굵은 글씨로 튀지 않게 평범한 문장 속에 흘려 두었으니 눈으로 훑기보다는 찬찬히 읽어야 눈에 들어옵니다.
접근성 — 아홉 개가 reduced-motion에서 남기는 것
reduced-motion 에서 남는 것
운영체제의 동작 줄이기 설정을 켜 둔 방문자에게는 prefers-reduced-motion: reduce 미디어 쿼리 안에서 아홉 개 모두 움직임을 멈추도록 짜 두었습니다. 01·04·05번처럼 마우스 호버 효과로만 움직이던 항목은 단순한 정지 상태(화살표가 늘어나지 않은 기본 모양)로 남아도 정보 손실이 없습니다. 02·03·07·08·09번처럼 늘 움직이던 항목은 순환 애니메이션의 한 프레임에 고정되는데, 02·07번은 투명도를 중간값(각각 .6·.7)으로 굳혀 두고 03·08·09번은 주기가 시작되는 정지 자세로 고정해 방향성만 남깁니다. 06번 SVG 화살표는 선이 다 그려진 완성 상태(stroke-dashoffset: 0)로 고정해 정보가 사라지지 않게 했습니다. 아홉 개 모두에 새로 얹은 오버슛 정착과 화살촉 트레일 파티클도 같은 미디어 쿼리 안에서 opacity: 0 !important·animation: none !important로 함께 꺼지므로, 동작 줄이기를 켠 방문자 화면에는 트레일이 한 점도 남지 않습니다.
이어서 볼 글
화살표 방향을 CSS만으로 바꾸는 다른 예시가 궁금하다면 CSS 아이콘·SVG 애니메이션 글도 참고할 만하고, 전체 목록은 CSS 애니메이션 카테고리에서 모아 볼 수 있습니다. stroke-dashoffset의 정확한 동작 방식은 MDN 문서에서 확인할 수 있습니다.
FAQ — 자주 묻는 질문
화살표 애니메이션에 자바스크립트가 꼭 필요한가요?
이 아홉 개는 전부 순수 CSS(그리고 06번은 SVG)로만 만들어졌습니다. 호버 반응은 :hover 셀렉터로, 자동 재생은 @keyframes로 처리해 자바스크립트가 한 줄도 없습니다. 나중에 더한 고무줄식 오버슛과 화살촉 트레일 파티클도 transition-timing-function과 지연된 @keyframes만으로 구현했습니다.
css 버튼 호버 애니메이션으로는 01번과 04번 중 어떤 걸 써야 하나요?
텍스트 링크 옆에 붙이는 간단한 신호라면 01번 몸통 늘어남으로 충분합니다. 원형 버튼처럼 독립된 클릭 영역이 있고 화살표가 그 안에서 반응해야 한다면 04번처럼 두 화살표가 자리를 바꾸는 방식이 더 눈에 띕니다.
zip 안의 vanilla와 react 코드가 다른가요?
눈에 보이는 결과는 동일하고 껍데기만 다릅니다. vanilla 쪽 SCSS 변수 세 개($duration $easing $color)를 React 판에서는 컴포넌트 props로 받아 CSS 커스텀 속성(--duration --easing --color)으로 넘기고, 나머지 애니메이션 규칙은 module.scss가 그대로 이어받습니다. 화살표를 아이콘 버튼으로 키우고 싶다면 아이콘·플로팅 버튼 패턴에 누르고 펼치는 동작이 있습니다.









