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
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).
01Extending arrow
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 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
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. All three chevrons run the exact same keyframes; only a staggered animation-delay of 180ms and 360ms keeps them from moving in unison.
.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
A single downward chevron bounces up and down on its own, gently nudging visitors to scroll for more. 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.
.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
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. 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.
.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
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. 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
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 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
Three chevrons stacked one above the other brighten and dip in sequence, flowing like a wave without needing a hover. It's the same cw-pulse keyframes on all three, staggered 150ms and 300ms apart — of the nine, this is the only one where every single captured frame shows some change, since the three staggered copies never all sit still at once.
.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
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. 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
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. 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?
The real bug here showed up in item 08's mouse-shaped scroll hint. 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.20%, comfortably clear. 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.11% and item 01's stretch at 0.14%, both because a single thin line or a 12px icon simply doesn't have many pixels to change in the first place. 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
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.
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
Do any of these arrow animations 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.