GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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.

Extending arrow
Open in a new tab

01 · Extending arrow — Measured area 0.163% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXtranslateXhover

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.

Looping slide arrow
Open in a new tab

02 · Looping slide arrow — Measured area 1.146% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacitytranslateXstagger

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.

Bounce downward
Open in a new tab

03 · Bounce downward — Measured area 0.637% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYkeyframesauto

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.

Circle + arrow hover
Open in a new tab

04 · Circle + arrow hover — Measured area 1.206% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXopacityhover

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.

External-link 45deg rotate
Open in a new tab

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

rotatetranslatehover

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.

SVG-drawn arrow
Open in a new tab

06 · SVG-drawn arrow — Measured area 2.082% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

SVGstroke-dashoffsetpathLength

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.

Chevron wave
Open in a new tab

07 · Chevron wave — Measured area 0.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacitytranslateYstagger

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.

Mouse-shaped scroll hint
Open in a new tab

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

translateYopacityborder-radius

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.

Double arrow swap
Open in a new tab

09 · Double arrow swap — Measured area 0.637% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

translaterotatestagger

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.

Enter the archive password

The password is inside this article. You will find it as you read.