# Animated Icons (Moonarc)

Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself. Each stroke is its own path moved by translate, rotate, scale, opacity and dash offset, so every pair works in every engine; play/pause also morphs with d: path() where the property exists. The state is a prop or comes from the button around it (aria-pressed, aria-expanded, data-state="done", data-on). Zero JavaScript.

- Import: `import AnimatedIcons from '@moonarc/core/AnimatedIcons'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/animated-icons.json`
- Tier A · category ui · trigger click
- Readout: `<AnimatedIcons icon="menu">`
- Browser support: limited (Chrome 52 · Firefox 97 · not Safari); elsewhere: play ↔ pause cross-fades instead of morphing where d: path() is not a CSS property (Safari)
- Measured cost: 0 B JS (CSS 8.3 kB raw)
- Page: https://moonarc.dev/components/animated-icons/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `icon` | `'menu' | 'copy' | 'play' | 'plus' | 'check' | 'sun'` | none | Which pair. |
| `state` | `'on' | 'off'` | none | Second state; on or off pins it. Unset, the icon is on while any ancestor has aria-pressed="true", aria-expanded="true", data-state="done" or data-on, however far out: an open menu around a button counts too, so pass off to keep an icon out of it. |
| `size` | `string` | `'1em'` | Any CSS length; follows the button's font-size by default. |
| `stroke` | `number` | `2` | Stroke width on the 24-unit grid. |

## Usage

```astro
<button type="button" aria-expanded="false" aria-label="Menu" onclick="this.ariaExpanded = this.ariaExpanded !== 'true'">
  <AnimatedIcons icon="menu" />
</button>
<AnimatedIcons icon="play" state="on" size="1.5em" />
```

## Reduced motion

The strokes switch at once.

## With ClientRouter

CSS-only; the state is on the element or its button and goes with the page.

## Craft

- The state is read from the button's own ARIA, so the attribute a screen reader needs is the one that moves the icon. There is no second source of truth.
- Individual transform properties per stroke with transform-box: fill-box: the burger's outer lines slide to the middle and turn 45°, the middle line collapses; nothing is a path morph.
- Play ↔ pause morphs its two halves with d: path() where it can (same command list on both ends), and cross-fades where it cannot, so the fallback is a clean state change, not a broken morph.
- Copy → check draws the check after the rectangles have shrunk away (a third of the duration later), so the two do not overlap mid-flight.
- Everything sits on the preset spring; a snap preset makes the burger click, ambient makes it breathe.

## Replaces

- AnimatedIcons (Skiper UI)
- icon morph (Motion Primitives)
- lucide-react animated icons

## Source

```astro
---
/**
 * AnimatedIcons — six line icons, each with a second state it morphs into,
 * zero JS: menu ↔ close, plus ↔ close, copy ↔ check, play ↔ pause,
 * sun ↔ moon, and a check that draws itself. Every stroke is its own path
 * moved by the individual transform properties (translate, rotate, scale)
 * plus opacity and a dash offset, so each pair works in every engine; play
 * ↔ pause additionally morphs its two halves with `d: path()` where the
 * property exists (Chrome, Firefox) and cross-fades elsewhere. The state is
 * the `state` prop, or read from the button around it: aria-pressed,
 * aria-expanded, data-state="done" or data-on — so CopyButton, ThemeToggle
 * and a burger menu switch their icon without a line of script. Any such
 * ancestor counts, however far out; state="off" pins the icon off.
 */
import type { HTMLAttributes } from 'astro/types';

type Icon = 'menu' | 'copy' | 'play' | 'plus' | 'check' | 'sun';

interface Props extends HTMLAttributes<'span'> {
  /** Which pair. */
  icon: Icon;
  /** Second state; 'on' or 'off' pins it. Unset: on while any ancestor has aria-pressed="true", aria-expanded="true", data-state="done" or data-on. */
  state?: 'on' | 'off';
  /** Any CSS length; default 1em, so it follows the button's font-size. */
  size?: string;
  /** Stroke width on the 24-unit grid. */
  stroke?: number;
}

const { icon, state, size = '1em', stroke = 2, class: className, style, ...rest } = Astro.props;
const vars = [`--ma-icon-size:${size}`, `--ma-icon-stroke:${stroke}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<span class:list={['ma-icon', className]} data-icon={icon} data-on={state === 'on' ? '' : undefined} data-off={state === 'off' ? '' : undefined} aria-hidden="true" style={vars} {...rest}>
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
    {icon === 'menu' && (
      <>
        <path class="ma-icon__a" d="M4 7h16" />
        <path class="ma-icon__b" d="M4 12h16" />
        <path class="ma-icon__c" d="M4 17h16" />
      </>
    )}
    {icon === 'plus' && (
      <>
        <path class="ma-icon__a" d="M12 4v16" />
        <path class="ma-icon__b" d="M4 12h16" />
      </>
    )}
    {icon === 'check' && <path class="ma-icon__a" d="M5 12l4.5 4.5L19 7" pathLength="1" />}
    {icon === 'copy' && (
      <>
        <rect class="ma-icon__a" x="9" y="9" width="11" height="11" rx="2" />
        <path class="ma-icon__b" d="M5 15V6a2 2 0 0 1 2-2h9" />
        <path class="ma-icon__c" d="M5 12l4.5 4.5L19 7" pathLength="1" />
      </>
    )}
    {icon === 'play' && (
      <>
        <path class="ma-icon__a" d="M7 5 L12 8 L12 16 L7 19 Z" fill="currentColor" />
        <path class="ma-icon__b" d="M12 8 L17 12 L17 12 L12 16 Z" fill="currentColor" />
        <path class="ma-icon__c" d="M6 5 L10 5 L10 19 L6 19 Z" fill="currentColor" />
        <path class="ma-icon__d" d="M14 5 L18 5 L18 19 L14 19 Z" fill="currentColor" />
      </>
    )}
    {icon === 'sun' && (
      <>
        <circle class="ma-icon__a" cx="12" cy="12" r="4" />
        <path class="ma-icon__b" d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
        <path class="ma-icon__c" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
      </>
    )}
  </svg>
</span>

<style is:global>
  @layer components {
    :where(.ma-icon) {
      display: inline-block;
      width: var(--ma-icon-size, 1em);
      height: var(--ma-icon-size, 1em);
      vertical-align: -0.125em;
      flex: none;
    }
    :where(.ma-icon > svg) {
      display: block;
      width: 100%;
      height: 100%;
      overflow: visible;
      stroke-width: var(--ma-icon-stroke, 2);
    }
    :where(.ma-icon > svg > *) {
      transform-box: fill-box;
      transform-origin: center;
      transition:
        translate var(--ma-duration) var(--ma-ease),
        rotate var(--ma-duration) var(--ma-ease),
        scale var(--ma-duration) var(--ma-ease),
        opacity var(--ma-duration) var(--ma-ease),
        stroke-dashoffset var(--ma-duration) var(--ma-ease),
        d var(--ma-duration) var(--ma-ease);
    }

    /* off states that are not the drawn attribute */
    :where(.ma-icon[data-icon='check'] .ma-icon__a),
    :where(.ma-icon[data-icon='copy'] .ma-icon__c) {
      stroke-dasharray: 1;
      stroke-dashoffset: 1;
    }
    :where(.ma-icon[data-icon='play'] .ma-icon__c),
    :where(.ma-icon[data-icon='play'] .ma-icon__d),
    :where(.ma-icon[data-icon='sun'] .ma-icon__c) {
      opacity: 0;
      scale: 0.6;
    }
    :where(.ma-icon[data-icon='sun'] .ma-icon__c) {
      rotate: -90deg;
    }

    /* on: the prop, or any ancestor in an on state, unless the prop pins it off */
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='menu'] .ma-icon__a) {
      translate: 0 5px;
      rotate: 45deg;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='menu'] .ma-icon__b) {
      scale: 0 1;
      opacity: 0;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='menu'] .ma-icon__c) {
      translate: 0 -5px;
      rotate: -45deg;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='plus'] > svg > *) {
      rotate: 45deg;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='check'] .ma-icon__a),
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__c) {
      stroke-dashoffset: 0;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__c) {
      transition-delay: calc(var(--ma-duration) / 3);
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__a),
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__b) {
      scale: 0.5;
      opacity: 0;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__a),
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__b) {
      opacity: 0;
      scale: 0.6;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__c),
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__d) {
      opacity: 1;
      scale: 1;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='sun'] .ma-icon__a),
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='sun'] .ma-icon__b) {
      opacity: 0;
      scale: 0.5;
      rotate: 90deg;
    }
    :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='sun'] .ma-icon__c) {
      opacity: 1;
      scale: 1;
      rotate: 0deg;
    }

    /* play ↔ pause: where `d` is a CSS property the two halves morph into the two bars; the bar paths are then never shown */
    @supports (d: path('M0 0h1')) {
      :where(.ma-icon[data-icon='play'] .ma-icon__c),
      :where(.ma-icon[data-icon='play'] .ma-icon__d) {
        display: none;
      }
      :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__a) {
        d: path('M6 5 L10 5 L10 19 L6 19 Z');
        opacity: 1;
        scale: 1;
      }
      :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__b) {
        d: path('M14 5 L18 5 L18 19 L14 19 Z');
        opacity: 1;
        scale: 1;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      :where(.ma-icon > svg > *) {
        transition: none;
      }
    }
  }
</style>

```
