A highlight sweeps across the body of a button on a loop; hover lifts it on the preset's hover travel and adds a ring, and focus draws the same.
Components / UI
Arrow Button
A button whose arrow slides out on hover as a new one slides in. Two copies of the icon sit in one clipped cell, one translating out while the other comes in on the preset's spring. direction rotates the cell so up, down and left travel the way they point. Hover is read from the containing link or button too; focus draws the same. Zero JavaScript.
Live demo
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
CSS 6.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import ArrowButton from '@moonarc/core/ArrowButton';
---
<ArrowButton as="a" href="/components/">Browse the catalogue</ArrowButton>
<ArrowButton direction="down">Read on</ArrowButton>
<a href="/docs/" class="my-link"><ArrowButton as="span">Docs</ArrowButton></a>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/arrow-button.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* ArrowButton — the arrow leaves and a new one arrives on hover, zero JS.
* Two copies of the icon share one clipped cell; hover translates the first
* out and the second in on the preset's spring, so the arrow reads as one
* that kept going. `direction` rotates the cell, so up, down and left travel
* the way they point; on a right-to-left page right and left are mirrored,
* so right still points past the end of the label. Hover is read from the
* button itself or from the link or button around it; :focus-visible draws
* the same state.
*/
import type { HTMLAttributes } from 'astro/types';
type Direction = 'right' | 'left' | 'up' | 'down';
interface Props extends HTMLAttributes<'button'> {
/** Element to render. `span` for a label inside your own link or button. */
as?: 'button' | 'a' | 'span';
/** For `as="a"`. */
href?: string;
/** Where the arrow points and travels. */
direction?: Direction;
}
const ROT: Record<Direction, number> = { right: 0, down: 90, left: 180, up: -90 };
const { as: Tag = 'button', href, direction = 'right', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-arrow-rot:${ROT[direction]}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<Tag class:list={['ma-arrow', className]} data-dir={direction} href={Tag === 'a' ? href : undefined} type={Tag === 'button' ? 'button' : undefined} style={vars} {...rest}>
<span class="ma-arrow__label"><slot /></span>
<span class="ma-arrow__box" aria-hidden="true">
<svg class="ma-arrow__icon ma-arrow__icon--out" viewBox="0 0 16 16"><path d="M2 8h11M9 4l4 4-4 4" /></svg>
<svg class="ma-arrow__icon ma-arrow__icon--in" viewBox="0 0 16 16"><path d="M2 8h11M9 4l4 4-4 4" /></svg>
</span>
</Tag>
<style is:global>
@layer components {
:where(.ma-arrow) {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.65em 1.1em;
border: 1px solid var(--ma-edge);
border-radius: 0.6em;
background: var(--ma-panel);
color: var(--ma-ink);
font: inherit;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
cursor: pointer;
transition:
border-color var(--ma-duration-fast) var(--ma-ease-out),
background-color var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-arrow[data-dir='left'] .ma-arrow__box) {
order: -1;
}
:where(.ma-arrow__box) {
display: inline-grid;
width: 1.1em;
height: 1.1em;
overflow: clip;
rotate: var(--ma-arrow-rot, 0deg);
flex: none;
}
:where(.ma-arrow__icon) {
grid-area: 1 / 1;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-arrow__icon--in) {
translate: -110% 0;
}
/* right to left, a horizontal arrow is mirrored: right points to the inline end (left on the page), away from the
label as it does left to right, and travels that way; left mirrors back. The mirror is on the cell, so the travel
inside it follows. [dir] and not :dir(): Chrome has :dir() only from 120, and a build that targets an older
Chrome rewrites it into a guess from lang, which a page that sets only dir never matches. An ltr island one level
inside an rtl page is set back */
:where([dir='rtl'] .ma-arrow:is([data-dir='right'], [data-dir='left']) .ma-arrow__box, .ma-arrow[dir='rtl']:is([data-dir='right'], [data-dir='left']) .ma-arrow__box) {
scale: -1 1;
}
:where([dir='rtl'] [dir='ltr'] .ma-arrow .ma-arrow__box, [dir='rtl'] .ma-arrow[dir='ltr'] .ma-arrow__box) {
scale: none;
}
@media (hover: hover) {
:where(.ma-arrow:hover),
:where(:is(a, button, label):hover .ma-arrow) {
border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
}
:where(.ma-arrow:hover .ma-arrow__icon--out),
:where(:is(a, button, label):hover .ma-arrow .ma-arrow__icon--out) {
translate: 110% 0;
}
:where(.ma-arrow:hover .ma-arrow__icon--in),
:where(:is(a, button, label):hover .ma-arrow .ma-arrow__icon--in) {
translate: 0 0;
}
}
:where(.ma-arrow:focus-visible),
:where(:is(a, button):focus-visible .ma-arrow) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
:where(.ma-arrow:focus-visible .ma-arrow__icon--out),
:where(:is(a, button):focus-visible .ma-arrow .ma-arrow__icon--out) {
translate: 110% 0;
}
:where(.ma-arrow:focus-visible .ma-arrow__icon--in),
:where(:is(a, button):focus-visible .ma-arrow .ma-arrow__icon--in) {
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-arrow__icon) {
transition: none;
}
:where(.ma-arrow__icon--out) {
translate: 0 0;
}
:where(.ma-arrow__icon--in) {
translate: -110% 0;
}
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
as | 'button' | 'a' | 'span' | 'button' | Element to render; span for a label inside your own link. |
href | string | none | For as="a". |
direction | 'right' | 'left' | 'up' | 'down' | 'right' | Where the arrow points and travels. On a right-to-left page right and left are mirrored: right points to the end of the line (left on the page), past the label, as it does in English. |
Reduced motion
The arrow stays put; hover changes the border only.
With ClientRouter
CSS-only; nothing to rebind.
Why it is built this way
- The travel is 110 % of the cell, not 100 %: the leaving arrow clears the clip before the arriving one lands, so at no frame are two heads visible.
- One rotate on the cell handles four directions; the icons and the keyframes are the same, so the four variants cost one rule. Right to left, a scale of -1 on the same cell mirrors right and left under a dir="rtl" ancestor, so the arrow leaves past the label on either side. The selector reads the attribute because Chrome has :dir() only from 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never matches.
- Hover on the whole button or on the link around a span: the arrow acknowledges the whole target.
- On the preset spring (ui: 450 ms): a snap preset makes it a tick, ambient a glide. The preset sets the feel.
Replaces: ArrowButton (Animata) · InteractiveHoverButton (Magic UI) · link arrow hover (Tailwind Plus). See the migration table.
Related
Text Roll
TextA label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves.
Underline Hover
TextA link underline that enters from the left on hover and leaves to the right, so the exit continues the entrance instead of rewinding it.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown