npx astro add moonarcA 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.
Components / UI
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, 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
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
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).
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 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" />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/animated-icons.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
The strokes switch at once.
ClientRouterCSS-only; the state is on the element or its button and goes with the page.
Replaces: AnimatedIcons (Skiper UI) · icon morph (Motion Primitives) · lucide-react animated icons. See the migration table.
npx astro add moonarcA 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.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown