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.
<path>s: a shaft and an arrow head, drawn with stroke, not fill.direction map swaps the two path shapes (and the scale origin) for left vs right.motion-safe: make it feel bouncy while still respecting reduced motion.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:
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 constThe 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.
The shaft scales horizontally and the head slides in the same direction. Both are plain CSS transforms on a `transition-transform`:
const SHAFT_SCALE = 'motion-safe:group-hover:scale-x-[1.45] …'... and per direction
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.
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:
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 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:
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.
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>
)
}direction map: swaps path shapes and the scale origin for left vs right.group, group/button, and group/nav because each class is spelled out (Tailwind only reads literal strings).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.