Keyframe
Keyframe · The log

Arrow Animation CSS: Bouncing, Sliding, and Scroll-Cue Arrows

July 18, 2026 · Uncategorized

An arrow points. That is its whole personality. Give it a little motion and it stops pointing and starts suggesting: go here, scroll down, this is the next thing. That shift, from static sign to live cue, is what arrow animation css is really for. An animated arrow is an affordance, a nudge toward the action you want someone to take.

This page covers the four arrows you will actually build: the hover nudge, the endless scroll bounce, the sliding link arrow, and a chevron drawn from nothing but borders. Then the part most tutorials skip, which is switching the loud one off for people who asked for less motion. It all builds on the mechanics in the main CSS animation guide, so head there if @keyframes or transform need a refresher.

Arrow animation css with translateX

Start with the smallest possible move. When you hover a “Read more” or a “Next” link, the arrow should shift a few pixels in the direction it points, like it is leaning toward where it wants to take you. That is translateX, and four pixels is plenty.

.next {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
}

.next .arrow {
  transition: transform 200ms ease-out;
}

.next:hover .arrow,
.next:focus-visible .arrow {
  transform: translateX(4px);
}

Why translateX and not margin-left? Because transform rides the compositor and never triggers layout, so the whole line of text does not reflow every frame. Nudging with margin or left works visually and then quietly costs you frames on anything that has to repaint. Reach for transform first, every time. The timing function matters here too: ease-out makes the arrow arrive quickly and settle, which reads as responsive.

Notice the hover trigger also covers :focus-visible. Keyboard users tab to that link, and if the nudge only fires on mouse hover, they never see the cue. One extra selector, and the affordance works for everyone.

The infinite bounce (alternate + infinite)

Here is where arrows go wrong. The bouncing scroll-down arrow, the one that bobs forever at the bottom of a hero, is the single most overused motion on the modern web. It is fine. It is also a small perpetual-motion machine in the corner of someone’s eye, and once you notice it you cannot stop. Build it, use it sparingly, and read the reduced-motion section before you ship it.

The mechanics are clean, though. A bounce moves between two positions and repeats. The lazy way writes both trips into the keyframes. The good way writes only the trip out and lets animation-direction: alternate handle the return.

.scroll-cue {
  animation: bob 1.2s ease-in-out infinite alternate;
}

@keyframes bob {
  from { transform: translateY(0); }
  to   { transform: translateY(10px); }
}

That is the entire bounce. alternate plays the keyframes forward, then backward, then forward, so two stops give you a smooth there-and-back. infinite keeps it looping. ease-in-out is doing quiet work: it slows the arrow at the top and bottom of each swing, which is what makes it feel like a bounce instead of a robotic slide. Try it with linear once and you will hear the difference, the motion goes mechanical.

Ten pixels of travel and a duration over a second. Anything faster looks anxious. If you want the arrow to bounce sideways instead, for a carousel “more this way” cue, swap translateY for translateX and you are done. Same two offsets, different axis.

Hover slide on a link arrow

What should a link arrow do on hover, exactly? The nudge earlier was subtle. The slide is its bolder sibling: the arrow travels further and can even pull away from its label, so the link feels like it is opening up. This is the effect on every well-made “View all” and “Learn more” link, and it is pure transition, no keyframes needed.

.link {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  color: #4f46e5;
  text-decoration: none;
}

.link .arrow {
  transition: transform 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* the arrow slides right; the gap grows with it */
.link:hover .arrow,
.link:focus-visible .arrow {
  transform: translateX(6px);
}

The custom cubic-bezier there is worth a look. It starts fast and eases into a soft landing, so the arrow shoots out and settles rather than crawling. You could use plain ease-out and be happy. The curve just gives it a touch more character. If bezier curves are a mystery, the timing function guide breaks down how to read those four numbers without guessing.

A hover slide pairs with a transition on transform for the same reason as the nudge: it stays off the layout path. The difference between this and a bouncing arrow is the trigger. A hover slide waits for intent and then reacts once. A bounce never waits and never stops. Same tools, opposite manners. If you like this kind of state-driven feedback, the same instincts drive button animation css, where hover, press, and loading all key off the pointer.

Drawing a chevron with borders

Four lines and no image. A chevron, the little > shape, is drawn by taking a square, keeping only two of its four borders, and rotating it. No SVG, no icon font, no HTTP request. It scales with font-size if you build it in em units, and it inherits color from currentColor so it matches its text automatically.

.chevron {
  display: inline-block;
  width: 0.5em;
  height: 0.5em;
  border-style: solid;
  border-color: currentColor;
  border-width: 0 2px 2px 0;   /* keep only the right and bottom edges */
  transform: rotate(-45deg);   /* -45deg points right, 45deg points down */
}

The logic: border-width: 0 2px 2px 0 paints only the right and bottom edges, which form an L. Rotate that L by -45deg and the corner points right, a chevron. The rotation map is worth memorizing because it is the same everywhere: -45deg right, 135deg left, -135deg up, 45deg down. Change one number, point it anywhere.

Now the good part. Because the chevron is a normal element with a transform already on it, you animate it exactly like the arrows above. Combine the rotation with a translate and it nudges on hover while staying pointed the right way.

.next .chevron {
  transition: transform 200ms ease-out;
}

/* keep the -45deg rotation, add the slide */
.next:hover .chevron {
  transform: rotate(-45deg) translate(2px, 2px);
}

One catch to remember: transform functions stack in order and they do not accumulate on their own. When you add the hover slide you have to restate rotate(-45deg), or the chevron snaps flat before it moves. Forget that and you will swear the browser is broken. It is not. Transform just has no memory of the base rule.

Reduced-motion behavior

Remember that endless scroll bounce? This is where it goes to be quiet. Of everything on this page, the infinite loop is the one most likely to bother someone with a vestibular condition, because it moves without being asked and it never stops. So when a visitor sets a reduced-motion preference, that is the first thing you kill.

@media (prefers-reduced-motion: reduce) {
  /* the looping bounce is the real offender: stop it dead */
  .scroll-cue {
    animation: none;
  }
  /* hover slides fire once on intent, so just make them instant */
  .next .arrow,
  .next .chevron,
  .link .arrow {
    transition: none;
  }
}

A static arrow still points. That is the reassurance that lets you cut motion without cutting meaning. The bounce loses its animation and sits still, the hover slides jump to their end state with no travel, and the page still tells you where to go. Nobody loses information, one group loses the nausea.

There is a subtler call for hover effects, and reasonable people split on it. Some strip the transition entirely under reduced motion, as above. Others keep a fast fade because a hover response you triggered on purpose is not the same as ambient movement you cannot escape. Both are defensible. The full argument, plus the pattern for handling it per component instead of one blunt override, lives in accessible animation with prefers-reduced-motion.

Ship the nudge and the chevron freely. Think twice before the infinite bounce. And whatever you animate, give the media query the last word.

Related

← Back to the log