Arrow Icon CSS Animations: 9 to Copy-Paste
An arrow icon CSS animation is a small arrow that moves by itself or reacts to a hover, nudging a visitor toward what to click next — each one below is one
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Extending arrow
- 02 Looping slide arrow
- 03 Bounce downward
- 04 Circle + arrow hover
- 05 External-link 45deg rotate
- 06 SVG-drawn arrow
- 07 Chevron wave
- 08 Mouse-shaped scroll hint
- 09 Double arrow swap
These nine aren't sorted by flashiness — they follow where an arrow actually sits on a page. First come the two that only move when a visitor's mouse finds them (01, 05), then the four that loop on their own without any hover at all (02, 03, 07, 08), then the one that swaps places inside a round button (04) and the one drawn like ink on paper (06), and last is the pair that trade places with each other (09). For arrows that bounce while they point, the bounce animations built that fall, and an underline drawing itself across a link is the companion CSS underline animations.
01Extending arrow
Where a css arrow hover effect fits
Hover the "Learn more" link next to it and the arrow's shaft stretches out sideways while its arrowhead slides along at the same time, both finishing together in 220ms so the whole shape reads as one arrow growing longer. The shaft's extend transition now runs on an elastic overshoot easing ($ease-elastic, cubic-bezier(.34,1.56,.64,1)), so it stretches slightly past its final length before settling back, and two small dots trail behind the arrowhead with a delayed, faded follow.

<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 · Extending arrow — Measured area 0.163% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
The head keeps a fixed 45-degree tilt throughout — only its position and the shaft's length change.
.ae-link__shaft {
transform-origin: left center;
transform: scaleX(1);
transition: transform 220ms $easing;
}
.ae-link__head {
transform: translateX(0) rotate(45deg);
transition: transform 220ms $easing;
}
.ae-link:hover .ae-link__shaft { transform: scaleX(1.8); }
.ae-link:hover .ae-link__head { transform: translateX(10px) rotate(45deg); }
02Looping slide arrow
What moves
Three chevrons fade in and drift rightward one after another, forever, with no hover needed — the kind of motion that tells a visitor "there's a next step" before they've touched anything.

<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 · Looping slide arrow — Measured area 1.146% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
All three chevrons run the exact same keyframes; only a staggered animation-delay of 180ms and 360ms keeps them from moving in unison. Each cycle now stops with an overshoot-then-settle (26%→38%) rather than sliding straight to its resting spot, and one smaller, dimmer trailing chevron-dot flows behind the three, arriving even later.
.sl-chevron {
transform: rotate(45deg) translateX(-8px);
opacity: 0;
}
@keyframes sl-flow {
0% { opacity: 0; transform: rotate(45deg) translateX(-8px); }
30%, 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--2 { animation-delay: 180ms; }
.sl-chevron--3 { animation-delay: 360ms; }
03Bounce downward
The moment it fires
A single downward chevron bounces up and down on its own, gently nudging visitors to scroll for more.

<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 · Bounce downward — Measured area 0.637% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
It's a continuous up-and-down spring rather than a fade or a swap, so almost every frame of the loop looks slightly different from the last. The bounce peak (42%→50%) now overshoots past its lowest point before settling back, and a faint trailing afterimage of the chevron follows a beat behind the real one.
.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 $duration $easing infinite; }
04Circle + arrow hover
What the user notices
Hover a round button and its arrow appears to swap sides — really, two separate arrows are stacked on top of each other, and hovering fades one out to the right while fading the other in from the left.

<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 · Circle + arrow hover — Measured area 1.206% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
The ring itself grows slightly and its border turns from dark to blue at the same time, adding to the changed pixel area beyond just the swapping arrows. Both the circle's scale and the arrow swap now run on $ease-elastic, so the ring and the incoming arrow overshoot before settling, and each of the two swapping arrows carries its own trailing dot.
.cx-btn {
transform: scale(1);
transition: transform 260ms $easing, border-color 260ms $easing;
}
.cx-btn:hover { transform: scale(1.06); border-color: $color; }
.cx-btn__arrow--a {
opacity: 1;
transform: rotate(45deg) translateX(0);
transition: transform 260ms $easing, opacity 260ms $easing;
}
.cx-btn__arrow--b {
opacity: 0;
transform: rotate(45deg) translateX(-16px);
transition: transform 260ms $easing, opacity 260ms $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); }
05External-link 45deg rotate
Where this shows up
Hover an external-link arrow and it rotates another 45 degrees while nudging slightly outward, quietly signaling that the link opens in a new tab. The rotation now runs on $ease-elastic too, so it overshoots 45deg slightly before settling, and a faint afterimage of the arrow trails a step behind the rotation.

<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 · External-link 45deg rotate — Measured area 0.103% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Of the nine, this icon holds the smallest margin above this site's minimum-motion measuring floor — it's a 12px icon, so there isn't much room left to shrink the duration any further without the change becoming too small to register as motion at all.
.er-link__arrow {
transform: rotate(0deg) translate(0, 0);
transition: transform 220ms $easing;
}
.er-link:hover .er-link__arrow {
transform: rotate(45deg) translate(2px, -2px);
}
06SVG-drawn arrow
What happens on screen
An arrow-shaped SVG line draws itself from tail to head as if someone were sketching it, holds for a beat once complete, then erases backward and starts again. The stroke-dashoffset draw itself is untouched, but the moment it finishes (58%→66%) a small circle pops at the arrowhead with an elastic overshoot scale, and a dim trailing copy of the path draws 90ms behind the main line.

<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 · SVG-drawn arrow — Measured area 2.082% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
The trick is stroke-dashoffset on a path built with pathLength="100", which turns the offset into a percentage regardless of the path's real pixel length — of the nine, this item registered both the strongest visible motion and the largest share of changed pixels, since a 4px stroke appearing and disappearing reads as a much bigger change than a small shape sliding.
.ds-path {
stroke-dasharray: 100;
animation: ds-draw $duration $easing infinite;
}
@keyframes ds-draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
07Chevron wave
What it looks like
Three chevrons stacked one above the other brighten and dip in sequence, flowing like a wave without needing a hover.

<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 · Chevron wave — Measured area 0.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
It's the same cw-pulse keyframes on all three, staggered 150ms and 300ms apart — of the nine, this is one of the two most consistently active, with motion in 23 of 24 captured frames, since the three staggered copies rarely all sit still at once. The pulse peak (42%→50%) now overshoots before settling back to its resting opacity, and one smaller, dimmer trailing chevron follows 450ms behind the three.
.cw-chevron {
transform: rotate(45deg) translateY(0);
opacity: .3;
}
@keyframes cw-pulse {
0%, 100% { opacity: .3; transform: rotate(45deg) translateY(0); }
50% { opacity: 1; transform: rotate(45deg) translateY(6px); }
}
.cw-chevron { animation: cw-pulse $duration $easing infinite; }
.cw-chevron--2 { animation-delay: 150ms; }
.cw-chevron--3 { animation-delay: 300ms; }
08Mouse-shaped scroll hint
When to use it
A small dot drifts downward inside a mouse-shaped outline and fades out near the bottom, then resets and repeats, a common cue at the base of a hero section. The dot's fall (55%→63%→70%) now overshoots before settling and fading, and a small trailing dot follows 120ms behind it, dimmer than the lead.

<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 · Mouse-shaped scroll hint — Measured area 0.236% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
This item shipped in a size too small to measure at first — see the pitfall below for what changed.
.ms-mouse {
border: 3px solid;
border-radius: $r-pill;
}
.ms-dot { opacity: 1; transform: translateY(0); }
@keyframes ms-scroll {
0% { opacity: 1; transform: translateY(0); }
70%, 100% { opacity: 0; transform: translateY(24px); }
}
.ms-dot { animation: ms-scroll $duration $easing infinite; }
09Double arrow swap
What you see
Two arrows sitting on the same diagonal line slide past each other in opposite directions, swapping ends on a loop — sort-direction toggles and refresh icons both lean on this "things trading places" read. The swap peak (46%→50%) now overshoots before settling, and each of the two crossing arrows gets its own small trailing dot, delayed 60ms behind.

<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 · Double arrow swap — Measured area 0.637% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
At the midpoint of every cycle the two arrows genuinely overlap, not just in a screenshot: that's a real crossing that happens on every pass, which is what the still poster image happens to capture.
.xs-arrow--a { transform: translate(-6px, 6px) rotate(-45deg); }
.xs-arrow--b { transform: translate(6px, -6px) rotate(135deg); }
@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); }
}
.xs-arrow--a { animation: xs-swap-a $duration $easing infinite; }
.xs-arrow--b { animation: xs-swap-b $duration $easing infinite; }
Where do these arrow animations break?
What goes wrong
The real bug here showed up in item 08's mouse-shaped scroll hint, a css animation arrow down cue that sits at the bottom of a hero. The first version drew the dot at 4×8px moving 14px, and rendering it out for measurement came back with a changed-pixel area of just 0.06% of the canvas — smaller than this site's own 0.08% minimum-motion floor, even though the dot was visibly sliding on screen. A tiny shape moving a tiny distance just doesn't move enough pixels to register as motion once it's measured rather than eyeballed. Growing the dot to 8×16px, its travel distance to 24px, and the mouse outline around it to 44×68px pushed the same measurement up to 0.236% (now including the trailing dot added alongside the overshoot), comfortably clear.
How to avoid it
The moment an icon drowns in a busy backdrop arrives even faster in the Memphis pattern landing, where dots and waves fill the screen, so the pattern gets a rule that lets it rest.
Item 01's stretching arrow carries a smaller version of the same lesson in its code rather than its size: the shaft's transform-origin: left center line is easy to delete by accident, and without it the shaft would grow from its own center instead of its left edge, pulling away from the arrowhead it's supposed to stay lined up with mid-stretch. Two items in this set ship with the least margin above that same measuring floor — item 05's rotate sits at 0.103% and item 01's stretch at 0.163%, both because a single thin line or a 12px icon simply doesn't have many pixels to change in the first place, even with the overshoot and trail added. The zip below needs a password to open, and it's sitting in this paragraph as an ordinary word rather than something boxed off: 8dggvep3. Read the sentence rather than scan it, and it's there.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Snappier stretch | Item 01's transition cut from 220ms to 140ms | The arrow now stretches almost instantly, closer to a flick than a glide |
| Slower wave | Item 07's cw-pulse duration stretched from 1.5s to 2.4s |
The three chevrons drift more like breathing than blinking |
| Wider swap | Item 09's translate distance widened from 6px to 12px |
The two arrows travel further before they cross, so the mid-cycle overlap reads more clearly |
Accessibility — what reduced motion leaves behind
What reduced motion leaves
Every item here turns itself off under prefers-reduced-motion: reduce, but what's left on screen isn't the same from item to item. Items 01, 04, and 05 only ever moved on hover, so reduced motion simply cancels that hover transition — visitors land on the same default arrow that was already there before any mouse arrived, and nothing about the link or button becomes harder to read. Among the four that loop on their own, two settle on a genuinely held mid-value rather than any position from their own animation: item 02's chevrons hold at a static 0.6 opacity and item 07's at 0.7, both numbers chosen for the frozen state rather than borrowed from either end of the loop. The other two auto-playing items land somewhere different — they snap to the exact pose their keyframes already use as a start or rest point, not a mid-value: item 03's chevron settles at translateY(0), the same 0%/100% keyframe it bounces back to on every cycle, and item 08's dot holds at its own 0% keyframe, fully visible at the top of the mouse outline rather than faded partway down. Item 09's two arrows freeze the same way, each locked to its own 0%/100% keyframe position on the diagonal rather than the midpoint where they'd otherwise cross. Item 06's SVG line is its own case entirely: reduced motion locks stroke-dashoffset at 0, the fully-drawn pose the animation already holds at 55–80% of its cycle, so the whole arrow stays visible and complete instead of resetting to a half-drawn or blank line. The elastic-overshoot layer added across all nine items — the springy stretch, rotate, bounce, swap, and scale motions — and every trailing dot, chevron, and afterimage that follows behind them are covered by the same guard, dropping out to animation: none !important and opacity: 0 !important so reduced motion sees none of the overshoot or the trail, only the settled end state described above.
Related reading
For more on drawing shapes with stroke-dashoffset, see 9 SVG Icon Animation CSS; the circle-hover swap in item 04 pairs well with the hover states covered in 9 CSS Button Animations. MDN documents the property behind item 06 in full at stroke-dashoffset.
FAQ
Does an arrow animation css build need JavaScript?
No. All nine run on pure CSS — :hover and @keyframes handle every hover reaction and every auto-playing loop, and item 06 adds nothing but plain SVG markup on top. There isn't a single line of JavaScript in the set.
Should I use item 01 or item 04 next to a link?
For a plain text link, item 01's stretching shaft is a light enough signal on its own. If there's a dedicated circular click target — a floating button, a round CTA — item 04's swap reads more clearly, since the whole button reacts, not just a thin line beside some text.
Is the React version in the zip different from the plain CSS?
The motion on screen is identical either way. The zip's vanilla SCSS reads three variables — $duration, $easing, $color — that the React components instead take as props and forward as CSS custom properties (--duration, --easing, --color), while every animation rule underneath stays the same. Growing an arrow into a full icon button is covered, press and expand, in the icon and floating button patterns.









