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".
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>Works in Vite, Create React App, Next.js (App Router needs the `'use client'` line, already included), Remix. The engine only touches the page inside `useEffect`, so server rendering is safe.
Create Butterflies.jsx next to the engine:
'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} />;
}Then use it:
import Butterflies from './Butterflies';
export default function Page() {
return <Butterflies options={{ butterflies: 6, fireflies: 14 }} style={{ height: '280px' }} />;
}Works in Vite + Vue 3 and Nuxt (wrap it in `<ClientOnly>` if you want to be extra safe; it only touches the page in `onMounted`).
Create Butterflies.vue next to the engine:
<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>Then use it:
<script setup>
import Butterflies from './Butterflies.vue';
</script>
<template>
<Butterflies :options="{ butterflies: 6, fireflies: 14 }" style="height:280px" />
</template>Works in Svelte 4 and 5 (the `export let` style still works in 5) and SvelteKit. Starts in `onMount`, so it never runs on the server.
Create Butterflies.svelte next to the engine:
<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>Then use it:
<script>
import Butterflies from './Butterflies.svelte';
</script>
<Butterflies options={{ butterflies: 6, fireflies: 14 }} style="height:280px" />Works in Solid and SolidStart. Starts in `onMount`, so it never runs on the server.
Create Butterflies.jsx next to the engine:
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} />;
}Then use it:
import Butterflies from './Butterflies';
export default function Page() {
return <Butterflies options={{ butterflies: 6, fireflies: 14 }} style={{ height: '280px' }} />;
}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
- Each critter picks a spot, floats toward it with a little sideways wobble (that is the flutter), then picks another.
- The butterfly turns smoothly to face where it flies. Wings are two SVG groups that squash with a CSS animation.
- It only animates while the box is on screen and the tab is visible, so it costs nothing when you scroll away.
- It watches
<html data-theme="dark">(or the visitor’s system setting) and swaps butterflies for fireflies by itself.
Options
Every option is optional. These are the defaults.
| 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 |
Tips
- Give the container a height and it fills it. The critters never leave the box.
- Put it behind a hero heading: position the container absolutely behind your text. The critters already ignore the mouse (
pointer-events: none). - Light mode for slow devices: skip mounting it when
matchMedia("(prefers-reduced-motion: reduce)").matches.