# Build a Hover-Expanding Arrow in React with Tailwind CSS

*Dmytro Bobryshev · 30 May 2026*

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

---

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.

> **Interactive Demo** — expanding-arrow: Read more
> *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:

```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`:

```typescript
const SHAFT_SCALE = 'motion-safe:group-hover:scale-x-[1.45] …'
```

... and per direction

```typescript
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:

```typescript
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:

```typescript
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

```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: ](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-origin)[**transform-origin**](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-origin)
- [MDN: ](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)[**prefers-reduced-motion**](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
- [Tailwind CSS: differentiating nested groups](https://tailwindcss.com/docs/hover-focus-and-other-states#differentiating-nested-groups)
- [Tailwind CSS: ](https://tailwindcss.com/docs/hover-focus-and-other-states#prefers-reduced-motion)[**motion-safe**](https://tailwindcss.com/docs/hover-focus-and-other-states#prefers-reduced-motion)