dmdm
AboutProjectsBlogContacts
Let's chatLet's chat

dmdm

Software Engineer & Vercel Enthusiast. Building fast, reactive web experiences.

Pages

  • About
  • Projects
  • Blog
  • Contacts

Connect

  • LinkedIn
  • GitHub
  • Twitter/X
  • Email

More

  • RSS Feed
  • Sitemap
Β© 2026 Dmytro Bobryshev. All rights reserved.Last updated by Dmytro on Jun 23, 2026, 05:07 PM
Back to blog
Expanding arrow cover

Build a Hover-Expanding Arrow in React with Tailwind CSS

Dmytro Bobryshev30 May 2026β€’4 min read

A tiny SVG arrow that stretches and glides on hover – pure CSS, spring easing, reduced-motion aware, and reusable across three Tailwind group scopes.

ReactTailwind CSSSVG AnimationFrontendUISVG

This is a tiny SVG arrow that comes alive on hover: the shaft stretches and the head glides outward, then springs back when you leave. There is no JavaScript and no animation library, just two SVG paths and a few Tailwind classes. It ships across this site inside links and buttons, and the same component handles arrows pointing left or right.

Small16px
Medium24px
Large40px
Interactive example ☝️

How it works

  • Two SVG <path>s: a shaft and an arrow head, drawn with stroke, not fill.
  • On hover, CSS scales the shaft along X and nudges the head sideways. That is the whole animation.
  • A small direction map swaps the two path shapes (and the scale origin) for left vs right.
  • The same component reacts to three different hover scopes: plain links, buttons, and post-navigation links.
  • A spring easing curve plus motion-safe: make it feel bouncy while still respecting reduced motion.

The SVG itself

The arrow is one <svg> with two paths. The shapes live in a small map keyed by direction, so picking left or right swaps both the geometry and the anchor point for the stretch:

expanding-arrow.tsx
const ARROWS = {
  left: {
    shaft: 'M5.5 12.002H19',
    head: 'M10.9999 18.002C10.9999 18.002 4.99998 13.583 4.99997 12.0019C4.99996 10.4208 11 6.00195 11 6.00195',
    shaftOrigin: 'origin-right',
    headShift: '…',
  },
  right: {
    shaft: 'M18.5 12L4.99997 12',
    head: 'M13 18C13 18 19 13.5811 19 12C19 10.4188 13 6 13 6',
    shaftOrigin: 'origin-left',
    headShift: '…',
  },
} as const

The SVG uses viewBox="0 0 24 24", stroke="currentColor", and aria-hidden="true" (it is decorative, so screen readers skip it). One detail matters: overflow-visible. When the shaft scales past its original width, it would otherwise get clipped by the SVG box. overflow-visible lets it grow freely.

Animating on hover

The shaft scales horizontally and the head slides in the same direction. Both are plain CSS transforms on a `transition-transform`:

expanding-arrow.tsx
const SHAFT_SCALE = 'motion-safe:group-hover:scale-x-[1.45] …'

... and per direction

expanding-arrow.tsx
headShift: 'motion-safe:group-hover:translate-x-[6px] …'

The trick is the scale origin. A right arrow anchors at origin-left, so it stretches toward the head. A left arrow anchors at origin-right, so it stretches the other way. The head gets a 6px translate-x so the tip keeps pace with the growing shaft. Two transforms, one transition, and the arrow looks like it is reaching forward.

One component, three group scopes

Here is the genuinely clever part. Tailwind's group-hover only watches the nearest group. But this arrow lives in three kinds of parents, and each names its group differently. The component handles all of them at once:

expanding-arrow.tsx
const SHAFT_SCALE = 'motion-safe:group-hover:scale-x-[1.45] motion-safe:group-hover/button:scale-x-[1.45] motion-safe:group-hover/nav:scale-x-[1.45]'

Why write the same scale three times instead of building the string in a loop? Because Tailwind only sees literal class strings in your source. It scans your files as plain text at build time, so a class assembled at runtime (like group-hover/${scope}:scale-x-[1.45] ) would never make it into the final CSS. Spelling out each scope by hand is what keeps the styles real. One component then drops cleanly into a plain <a>, a Button, or a nav link with zero extra wiring.

The spring feel and the button delay

The motion uses a custom easing token, --ease-spring, defined as cubic-bezier(0.16, 1, 0.3, 1). It overshoots slightly and settles, which is what gives the arrow its lively snap instead of a flat linear slide.

Buttons get one extra touch. Inside a Button, the label rolls up and a copy rolls in from below over 300ms. So the arrow waits for that to finish before expanding:

expanding-arrow.tsx
const BUTTON_DELAY = 'motion-safe:group-hover/button:delay-300'

Notice every animation class is prefixed with motion-safe:. Under prefers-reduced-motion: reduce, all of it switches off and the arrow simply sits still. Motion is a bonus, never a requirement.

The full component

expanding-arrow.tsx
import { cn } from '@/lib/utils'

const BUTTON_DELAY = 'motion-safe:group-hover/button:delay-300'

const SHAFT_SCALE = `motion-safe:group-hover:scale-x-[1.45] motion-safe:group-hover/button:scale-x-[1.45] motion-safe:group-hover/nav:scale-x-[1.45] ${BUTTON_DELAY}`

const ARROWS = {
  left: {
    shaft: 'M5.5 12.002H19',
    head: 'M10.9999 18.002C10.9999 18.002 4.99998 13.583 4.99997 12.0019C4.99996 10.4208 11 6.00195 11 6.00195',
    shaftOrigin: 'origin-right',
    headShift: `motion-safe:group-hover:-translate-x-[6px] motion-safe:group-hover/button:-translate-x-[6px] motion-safe:group-hover/nav:-translate-x-[6px] ${BUTTON_DELAY}`,
  },
  right: {
    shaft: 'M18.5 12L4.99997 12',
    head: 'M13 18C13 18 19 13.5811 19 12C19 10.4188 13 6 13 6',
    shaftOrigin: 'origin-left',
    headShift: `motion-safe:group-hover:translate-x-[6px] motion-safe:group-hover/button:translate-x-[6px] motion-safe:group-hover/nav:translate-x-[6px] ${BUTTON_DELAY}`,
  },
} as const

type Props = {
  direction?: keyof typeof ARROWS
  size?: number
  className?: string
}

export function ExpandingArrow(props: Props) {
  const { direction = 'right', size = 16, className } = props
  const arrow = ARROWS[direction]

  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={cn('overflow-visible', className)}
    >
      <path
        d={arrow.shaft}
        className={cn('transition-transform duration-300 ease-(--ease-spring)', arrow.shaftOrigin, SHAFT_SCALE)}
      />
      <path
        d={arrow.head}
        className={cn('transition-transform duration-300 ease-(--ease-spring)', arrow.headShift)}
      />
    </svg>
  )
}

Summary

  • Stroke SVG, two paths: a shaft and a head, animated with two CSS transforms.
  • direction map: swaps path shapes and the scale origin for left vs right.
  • Per-scope literal classes: one component works in group, group/button, and group/nav because each class is spelled out (Tailwind only reads literal strings).
  • Spring easing (cubic-bezier(0.16, 1, 0.3, 1)) gives the snap; a 300ms delay lets buttons finish their roll first.
  • motion-safe: everywhere so it respects reduced motion.

A lot of life, no JavaScript. Drop it next to any link or button and let CSS do the work.

Resources

  • MDN: transform-origin
  • MDN: prefers-reduced-motion
  • Tailwind CSS: differentiating nested groups
  • Tailwind CSS: motion-safe

Like what you read?

Let's build something together

Have a project in mind or just want to chat? I'm always open to new ideas and collaborations.

Get in touchGet in touchView projectsView projects
NextBuilding a Dark Mode Toggle with a Circular Wipe Animation in React