← Playground

Butterflies

Butterflies by day, fireflies by night

Hand it to your AI assistant

A ready-made skill: a Markdown file with instructions, the options, the full source and the wrappers for every framework. Save it as SKILL.md inside a folder named nikuma-butterflies in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add butterflies by day, fireflies by night to my site".

Download SKILL.md

SKILL.mdpreview
---
name: nikuma-butterflies
description: Add drifting butterflies (light theme) and fireflies (dark theme) inside a box or hero section. Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---

# Butterflies

Little butterflies drift around a box and flutter their wings. Switch the theme and they become glowing fireflies. This is the same code that flies around the hero of this site.

One dependency-free file. `mountButterflies(element, options)` builds everything inside `element` and **returns a `stop()` function** that removes it and cleans up all listeners. The framework wrappers below only call it in the right lifecycle hook.

## When to use

The user wants: butterflies by day, fireflies by night. Works in plain HTML, Astro, React (incl. Next.js), Vue (incl. Nuxt), Svelte (incl. SvelteKit) and Solid.

## How to add it

1. Save the file(s) under **Source** into the project (for example `src/lib/butterflies/`), keeping their names. They need no installation.
2. Vanilla / Astro / plain HTML: import the function in a `<script type="module">` and call it with the container element. Other frameworks: create the wrapper from **Framework wrappers**, then render it.
3. Give the container a size where the rules below say so, then check it in both light and dark theme.

```js
import { mountButterflies } from './butterflies.js';
const stop = mountButterflies(document.getElementById('stage'), { butterflies: 6, fireflies: 14 });
// stop();  // when the element is removed
```

## Options (all optional)

| Option | Default | What it does |
|---|---|---|
| `butterflies` | `6` | how many butterflies (fewer on small phones) |
| `fireflies` | `14` | how many fireflies |
| `mode` | `'auto'` | 'auto' follows the theme, or force 'butterflies' / 'fireflies' |
| `palette` | `5 pairs` | list of `[wingColor, lightWingColor]` pairs |

## Rules

- The container must have a height (the critters fly inside it).
- Do not mount it twice on the same element; call the returned `stop()` first.
- It reads the theme from `<html data-theme="dark|light">`; without that attribute it follows the system setting.
- Call `stop()` before mounting again on the same element (the framework wrappers do this on unmount).
- Do not edit the engine to change behaviour; use options. Respect `prefers-reduced-motion` (the engine does).

## Source

### `butterflies.js`

```js
// Butterflies (light) / fireflies (dark) drifting around inside a box.
// mountButterflies(el, options) -> stop()   No dependencies, no CSS file (styles are added for you).
//
// options (all optional):
//   butterflies  how many butterflies           default 6
//   fireflies    how many fireflies             default 14
//   mode         'auto' | 'butterflies' | 'fireflies'
//                'auto' = butterflies in the light theme, fireflies in the dark theme (reads <html data-theme="dark"> or the OS setting)
//   palette      list of [wing, wing-light] colour pairs
const PALETTE = [['#FF6B94', '#FFB3C7'], ['#F0953A', '#FFD08A'], ['#4B82E8', '#A9C6FF'], ['#A35BFF', '#D5B5FF'], ['#F2C230', '#FFE58A']];
const rand = (a, b) => a + Math.random() * (b - a);
const pick = (a) => a[Math.floor(Math.random() * a.length)];
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

const wings = ([a, b]) => `<svg viewBox="0 0 24 24" aria-hidden="true">
<g class="bfx-wing"><path d="M12 12C8 3 1 3 2 9c.4 3 5 4 10 3z" fill="${a}"/><path d="M12 12C7 13 3 16 5 20c2 2 6-2 7-8z" fill="${b}"/></g>
<g class="bfx-wing"><path d="M12 12c4-9 11-9 10-3-.4 3-5 4-10 3z" fill="${a}"/><path d="M12 12c5 1 9 4 7 8-2 2-6-2-7-8z" fill="${b}"/></g>
<rect x="11.2" y="6.5" width="1.6" height="11.5" rx=".8" fill="#3a2a1a"/></svg>`;

export function mountButterflies(el, o = {}) {
  css('bfx-css', `.bfx-box{position:absolute;inset:0;overflow:hidden;pointer-events:none}
.bfx{position:absolute;left:0;top:0;will-change:transform;width:var(--s,18px);height:var(--s,18px)}
.bfx-r{width:100%;height:100%;filter:drop-shadow(0 2px 2px rgba(0,0,0,.18))}.bfx svg{width:100%;height:100%;overflow:visible}
.bfx-wing{transform-box:view-box;transform-origin:12px 12px;animation:bfx-flap var(--flap,.32s) ease-in-out infinite alternate}
@keyframes bfx-flap{from{transform:scaleX(1)}to{transform:scaleX(.22)}}
.bfx-ff{width:var(--s,4px);height:var(--s,4px)}
.bfx-ff i{position:absolute;inset:0;border-radius:50%;background:#F4FF9A;box-shadow:0 0 6px 2px rgba(222,255,120,.85),0 0 18px 7px rgba(190,255,90,.35);animation:bfx-glow var(--glow,4s) ease-in-out var(--delay,0s) infinite}
@keyframes bfx-glow{0%,100%{opacity:.08;scale:.7}45%,60%{opacity:1;scale:1.15}}`);
  if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
  const box = document.createElement('div'); box.className = 'bfx-box'; box.setAttribute('aria-hidden', 'true'); el.append(box);
  const root = document.documentElement;
  const isDark = () => (root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches);
  const palette = o.palette || PALETTE;
  let list = [], W = 0, H = 0, raf = 0, last = 0, visible = true;
  const spot = () => ({ x: rand(22, Math.max(40, W - 22)), y: rand(22, Math.max(40, H - 22)) });

  function make(fly) {
    const d = document.createElement('div'), c = { el: d, ...spot(), vx: 0, vy: 0, heading: 0, wait: 0, ph: rand(0, 6.28), wf: rand(1.2, 2.4) };
    c.tx = c.x; c.ty = c.y;
    if (!fly) {
      d.className = 'bfx'; d.style.cssText = `--s:${rand(13, 20)}px;--flap:${rand(.24, .4).toFixed(2)}s`;
      d.innerHTML = `<div class="bfx-r">${wings(pick(palette))}</div>`;
      c.rot = d.firstChild; c.speed = rand(28, 52); c.turn = rand(1.6, 2.6); c.amp = rand(10, 22);
    } else {
      d.className = 'bfx bfx-ff'; d.style.cssText = `--s:${rand(3, 5).toFixed(1)}px;--glow:${rand(2.8, 6).toFixed(1)}s;--delay:${rand(-6, 0).toFixed(1)}s`;
      d.innerHTML = '<i></i>'; c.speed = rand(7, 18); c.turn = rand(.6, 1.2); c.amp = rand(4, 10);
    }
    box.append(d); return c;
  }
  function build() {
    list.forEach((c) => c.el.remove());
    const fly = o.mode === 'fireflies' || ((o.mode || 'auto') === 'auto' && isDark());
    const n = Math.round((fly ? o.fireflies ?? 14 : o.butterflies ?? 6) * (W < 420 ? .6 : 1));
    list = Array.from({ length: n }, () => make(fly));
  }
  function frame(t) {
    const dt = Math.min(.05, (t - last) / 1000 || .016); last = t;
    for (const c of list) {
      c.wait -= dt;
      if (c.wait <= 0 || Math.hypot(c.tx - c.x, c.ty - c.y) < 14) { const s = spot(); c.tx = s.x; c.ty = s.y; c.wait = rand(2, 5); }
      const d = Math.hypot(c.tx - c.x, c.ty - c.y) || 1, k = Math.min(1, dt * c.turn);
      c.vx += ((c.tx - c.x) / d * c.speed - c.vx) * k; c.vy += ((c.ty - c.y) / d * c.speed - c.vy) * k;
      c.ph += dt * c.wf;
      const sp = Math.hypot(c.vx, c.vy) || 1, wob = Math.sin(c.ph) * c.amp;      // a little sideways wobble = flutter
      c.x += c.vx * dt + (-c.vy / sp) * wob * dt * 2; c.y += c.vy * dt + (c.vx / sp) * wob * dt * 2;
      c.x = Math.min(W - 14, Math.max(14, c.x)); c.y = Math.min(H - 14, Math.max(14, c.y));
      c.el.style.transform = `translate3d(${c.x.toFixed(1)}px,${c.y.toFixed(1)}px,0)`;
      if (c.rot) {                                                                  // turn smoothly to face where it flies
        const target = Math.atan2(c.vy, c.vx) * 180 / Math.PI + 90, diff = ((((target - c.heading) % 360) + 540) % 360) - 180;
        c.heading += diff * Math.min(1, dt * 4); c.rot.style.rotate = c.heading.toFixed(1) + 'deg';
      }
    }
    raf = requestAnimationFrame(frame);
  }
  const sync = () => {
    const run = !document.hidden && visible && W > 0;
    if (run && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
    if (!run && raf) { cancelAnimationFrame(raf); raf = 0; }
  };
  const ro = new ResizeObserver(() => { const first = !W; W = box.clientWidth; H = box.clientHeight; if (first && W) build(); sync(); });
  ro.observe(el);
  const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }); io.observe(el);
  const mo = new MutationObserver(build); mo.observe(root, { attributes: true, attributeFilter: ['data-theme'] });   // swap butterflies <-> fireflies with the theme
  document.addEventListener('visibilitychange', sync);
  return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); mo.disconnect(); document.removeEventListener('visibilitychange', sync); box.remove(); };
}
```

## Framework wrappers

### React: `Butterflies.jsx`

```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountButterflies } from './butterflies.js';

export default function Butterflies({ options = {}, className, style }) {
  const ref = useRef(null);
  useEffect(() => mountButterflies(ref.current, options), []);   // the engine returns its own cleanup
  return <div ref={ref} className={className} style={style} />;
}
```

### Vue: `Butterflies.vue`

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountButterflies } from './butterflies.js';

const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountButterflies(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>

<template><div ref="root"></div></template>
```

### Svelte: `Butterflies.svelte`

```svelte
<script>
  import { onMount } from 'svelte';
  import { mountButterflies } from './butterflies.js';

  export let options = {};
  export let style = '';
  let klass = '';
  export { klass as class };
  let root;
  onMount(() => mountButterflies(root, options));   // returning the cleanup from onMount stops it on destroy
</script>

<div bind:this={root} class={klass} {style}></div>
```

### Solid: `Butterflies.jsx`

```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountButterflies } from './butterflies.js';

export default function Butterflies(props) {
  let root;
  onMount(() => onCleanup(mountButterflies(root, props.options || {})));
  return <div ref={root} class={props.class} style={props.style} />;
}
```

Get the code

One small file does all the work and has no dependencies. React, Vue, Svelte and Solid only add a few lines around it. Pick your tool below.

1. Copy the engine (same for every framework)

butterflies.js6 KB
// Butterflies (light) / fireflies (dark) drifting around inside a box.
// mountButterflies(el, options) -> stop()   No dependencies, no CSS file (styles are added for you).
//
// options (all optional):
//   butterflies  how many butterflies           default 6
//   fireflies    how many fireflies             default 14
//   mode         'auto' | 'butterflies' | 'fireflies'
//                'auto' = butterflies in the light theme, fireflies in the dark theme (reads <html data-theme="dark"> or the OS setting)
//   palette      list of [wing, wing-light] colour pairs
const PALETTE = [['#FF6B94', '#FFB3C7'], ['#F0953A', '#FFD08A'], ['#4B82E8', '#A9C6FF'], ['#A35BFF', '#D5B5FF'], ['#F2C230', '#FFE58A']];
const rand = (a, b) => a + Math.random() * (b - a);
const pick = (a) => a[Math.floor(Math.random() * a.length)];
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

const wings = ([a, b]) => `<svg viewBox="0 0 24 24" aria-hidden="true">
<g class="bfx-wing"><path d="M12 12C8 3 1 3 2 9c.4 3 5 4 10 3z" fill="${a}"/><path d="M12 12C7 13 3 16 5 20c2 2 6-2 7-8z" fill="${b}"/></g>
<g class="bfx-wing"><path d="M12 12c4-9 11-9 10-3-.4 3-5 4-10 3z" fill="${a}"/><path d="M12 12c5 1 9 4 7 8-2 2-6-2-7-8z" fill="${b}"/></g>
<rect x="11.2" y="6.5" width="1.6" height="11.5" rx=".8" fill="#3a2a1a"/></svg>`;

export function mountButterflies(el, o = {}) {
  css('bfx-css', `.bfx-box{position:absolute;inset:0;overflow:hidden;pointer-events:none}
.bfx{position:absolute;left:0;top:0;will-change:transform;width:var(--s,18px);height:var(--s,18px)}
.bfx-r{width:100%;height:100%;filter:drop-shadow(0 2px 2px rgba(0,0,0,.18))}.bfx svg{width:100%;height:100%;overflow:visible}
.bfx-wing{transform-box:view-box;transform-origin:12px 12px;animation:bfx-flap var(--flap,.32s) ease-in-out infinite alternate}
@keyframes bfx-flap{from{transform:scaleX(1)}to{transform:scaleX(.22)}}
.bfx-ff{width:var(--s,4px);height:var(--s,4px)}
.bfx-ff i{position:absolute;inset:0;border-radius:50%;background:#F4FF9A;box-shadow:0 0 6px 2px rgba(222,255,120,.85),0 0 18px 7px rgba(190,255,90,.35);animation:bfx-glow var(--glow,4s) ease-in-out var(--delay,0s) infinite}
@keyframes bfx-glow{0%,100%{opacity:.08;scale:.7}45%,60%{opacity:1;scale:1.15}}`);
  if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
  const box = document.createElement('div'); box.className = 'bfx-box'; box.setAttribute('aria-hidden', 'true'); el.append(box);
  const root = document.documentElement;
  const isDark = () => (root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches);
  const palette = o.palette || PALETTE;
  let list = [], W = 0, H = 0, raf = 0, last = 0, visible = true;
  const spot = () => ({ x: rand(22, Math.max(40, W - 22)), y: rand(22, Math.max(40, H - 22)) });

  function make(fly) {
    const d = document.createElement('div'), c = { el: d, ...spot(), vx: 0, vy: 0, heading: 0, wait: 0, ph: rand(0, 6.28), wf: rand(1.2, 2.4) };
    c.tx = c.x; c.ty = c.y;
    if (!fly) {
      d.className = 'bfx'; d.style.cssText = `--s:${rand(13, 20)}px;--flap:${rand(.24, .4).toFixed(2)}s`;
      d.innerHTML = `<div class="bfx-r">${wings(pick(palette))}</div>`;
      c.rot = d.firstChild; c.speed = rand(28, 52); c.turn = rand(1.6, 2.6); c.amp = rand(10, 22);
    } else {
      d.className = 'bfx bfx-ff'; d.style.cssText = `--s:${rand(3, 5).toFixed(1)}px;--glow:${rand(2.8, 6).toFixed(1)}s;--delay:${rand(-6, 0).toFixed(1)}s`;
      d.innerHTML = '<i></i>'; c.speed = rand(7, 18); c.turn = rand(.6, 1.2); c.amp = rand(4, 10);
    }
    box.append(d); return c;
  }
  function build() {
    list.forEach((c) => c.el.remove());
    const fly = o.mode === 'fireflies' || ((o.mode || 'auto') === 'auto' && isDark());
    const n = Math.round((fly ? o.fireflies ?? 14 : o.butterflies ?? 6) * (W < 420 ? .6 : 1));
    list = Array.from({ length: n }, () => make(fly));
  }
  function frame(t) {
    const dt = Math.min(.05, (t - last) / 1000 || .016); last = t;
    for (const c of list) {
      c.wait -= dt;
      if (c.wait <= 0 || Math.hypot(c.tx - c.x, c.ty - c.y) < 14) { const s = spot(); c.tx = s.x; c.ty = s.y; c.wait = rand(2, 5); }
      const d = Math.hypot(c.tx - c.x, c.ty - c.y) || 1, k = Math.min(1, dt * c.turn);
      c.vx += ((c.tx - c.x) / d * c.speed - c.vx) * k; c.vy += ((c.ty - c.y) / d * c.speed - c.vy) * k;
      c.ph += dt * c.wf;
      const sp = Math.hypot(c.vx, c.vy) || 1, wob = Math.sin(c.ph) * c.amp;      // a little sideways wobble = flutter
      c.x += c.vx * dt + (-c.vy / sp) * wob * dt * 2; c.y += c.vy * dt + (c.vx / sp) * wob * dt * 2;
      c.x = Math.min(W - 14, Math.max(14, c.x)); c.y = Math.min(H - 14, Math.max(14, c.y));
      c.el.style.transform = `translate3d(${c.x.toFixed(1)}px,${c.y.toFixed(1)}px,0)`;
      if (c.rot) {                                                                  // turn smoothly to face where it flies
        const target = Math.atan2(c.vy, c.vx) * 180 / Math.PI + 90, diff = ((((target - c.heading) % 360) + 540) % 360) - 180;
        c.heading += diff * Math.min(1, dt * 4); c.rot.style.rotate = c.heading.toFixed(1) + 'deg';
      }
    }
    raf = requestAnimationFrame(frame);
  }
  const sync = () => {
    const run = !document.hidden && visible && W > 0;
    if (run && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
    if (!run && raf) { cancelAnimationFrame(raf); raf = 0; }
  };
  const ro = new ResizeObserver(() => { const first = !W; W = box.clientWidth; H = box.clientHeight; if (first && W) build(); sync(); });
  ro.observe(el);
  const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }); io.observe(el);
  const mo = new MutationObserver(build); mo.observe(root, { attributes: true, attributeFilter: ['data-theme'] });   // swap butterflies <-> fireflies with the theme
  document.addEventListener('visibilitychange', sync);
  return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); mo.disconnect(); document.removeEventListener('visibilitychange', sync); box.remove(); };
}

Save it in your project (for example src/butterflies/). You never need to edit it: everything is an option.

2. Use it in your project

Plain HTML, Astro, Eleventy, WordPress, anything. No build step needed: a `<script type="module">` is enough.

Use it:

<div id="stage" style="height:280px"></div>

<script type="module">
  import { mountButterflies } from './butterflies.js';

  const stop = mountButterflies(document.getElementById('stage'), { butterflies: 6, fireflies: 14 });
  // when you are done (for example when a page closes): stop();
</script>

Little butterflies drift around a box and flutter their wings. Switch the theme and they become glowing fireflies. This is the same code that flies around the hero of this site.

How it works

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
butterflies6how many butterflies (fewer on small phones)
fireflies14how many fireflies
mode'auto'‘auto’ follows the theme, or force ‘butterflies’ / ‘fireflies’
palette5 pairslist of [wingColor, lightWingColor] pairs

Tips

Nature does not hurry, yet everything is accomplished.
Lao Tzu