Skip to content

Components / UI

Animated Icons

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 8.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 52 · Firefox 97 · not Safari; needs d-path. Elsewhere: play ↔ pause cross-fades instead of morphing where d: path() is not a CSS property (Safari).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import AnimatedIcons from '@moonarc/core/AnimatedIcons';
---
<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" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/animated-icons.json

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

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

Props

PropTypeDefaultDescription
icon'menu' | 'copy' | 'play' | 'plus' | 'check' | 'sun'noneWhich pair.
state'on' | 'off'noneSecond 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.
sizestring'1em'Any CSS length; follows the button's font-size by default.
strokenumber2Stroke width on the 24-unit grid.

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.

Why it is built this way

Replaces: AnimatedIcons (Skiper UI) · icon morph (Motion Primitives) · lucide-react animated icons. See the migration table.

npx astro add moonarc
click · check · back

A button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration.

every browserclick

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

click · the page changes from the button
ThemeScript · 473 B raw, inline in <head>

Cycles system → light → dark, and the page changes theme through a circle that grows from the click, on a same-document view transition.

newly · Chrome 111 · Firefox 144 · Safari 18click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown