# Arrow Button (Moonarc)

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.

- Import: `import ArrowButton from '@moonarc/core/ArrowButton'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/arrow-button.json`
- Tier A · category ui · trigger hover
- Readout: `<ArrowButton direction="right">`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 6.0 kB raw)
- Page: https://moonarc.dev/components/arrow-button/

## 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. |

## Usage

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

## Reduced motion

The arrow stays put; hover changes the border only.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- 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)

## Source

```astro
---
/**
 * 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>

```
