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-chimes in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add glass wind chimes with a soft ting to my site".
SKILL.mdpreview
---
name: nikuma-chimes
description: Add Japanese glass wind chimes that sway, react to the mouse and ring softly. Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---
# Wind chimes
Four glass furin hang from the top of the box. They sway in a breeze, swing when the mouse brushes past and ring with a soft glass "ting". Click once anywhere to wake the sound (browsers require that), then brush them.
One dependency-free file. `mountChimes(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: glass wind chimes with a soft ting. 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/chimes/`), 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 { mountChimes } from './chimes.js';
const stop = mountChimes(document.getElementById('stage'), { sound: true, random: true });
// stop(); // when the element is removed
```
## Options (all optional)
| Option | Default | What it does |
|---|---|---|
| `sound` | `true` | glass ting on/off |
| `random` | `true` | a breeze rings one now and then |
| `count` | `4` | how many chimes, 1 to 4 |
## Rules
- The audio engine is shared and survives page changes (single-page sites do not need another click).
- It never rings while the audio engine is asleep, so nothing bursts out later.
- 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
### `chimes.js`
```js
// Japanese glass wind chimes (furin): a little pendulum physics, a breeze now and then, a nudge when the mouse brushes them, and a soft glass "ting".
// mountChimes(el, options) -> stop() Hangs from the top edge of `el` (give `el` a height of about 220px). No dependencies.
//
// options (all optional):
// sound true = glass ting. Browsers only allow sound after the visitor's first click, tap or key press (see "Sound" below) default true
// random true = a breeze rings one now and then default true
// count how many chimes (1 to 4) default 4
//
// Sound: the audio engine is shared by every chime on the page and lives outside the component. It wakes up on the first real click / tap / key press,
// stays awake while you move between pages, and never queues sounds while it sleeps (so nothing bursts out later).
const SPEC = [
{ tint: '#7fb7e6', cl: 0, t: 2.7, f: 2093 }, { tint: '#e58fa8', cl: 38, t: 2.2, f: 2349 },
{ tint: '#9ccf8a', cl: 16, t: 3.0, f: 1760 }, { tint: '#c9a7f0', cl: 54, t: 2.4, f: 2637 },
];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };
// ── shared audio engine ───────────────────────────────────────────────────────
let actx = null, unlockOn = false;
const UNLOCK = ['pointerdown', 'pointerup', 'mousedown', 'touchend', 'keydown', 'click']; // every event a browser accepts as "the visitor did something"
function unlock() {
try {
if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
if (actx.state !== 'running') {
const s = actx.createBufferSource(); s.buffer = actx.createBuffer(1, 1, 22050); s.connect(actx.destination); s.start(0); // silent blip: wakes iOS / Safari
actx.resume?.();
}
if (actx.state === 'running') offUnlock(); else actx.onstatechange = () => (actx.state === 'running' ? offUnlock() : onUnlock());
} catch {}
}
function onUnlock() { if (unlockOn) return; unlockOn = true; UNLOCK.forEach((e) => addEventListener(e, unlock, { passive: true, capture: true })); }
function offUnlock() { unlockOn = false; UNLOCK.forEach((e) => removeEventListener(e, unlock, { capture: true })); }
function ding(f, v) {
if (!actx) return;
if (actx.state !== 'running') { actx.resume?.(); return; } // still asleep: stay quiet
const t = actx.currentTime, g = actx.createGain();
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(Math.min(.1, v), t + .004); g.gain.exponentialRampToValueAtTime(.0001, t + 1.8); g.connect(actx.destination);
[1, 2.76].forEach((m, i) => { const o = actx.createOscillator(); o.frequency.value = f * m; o.type = 'sine'; const og = actx.createGain(); og.gain.value = i ? .25 : 1; o.connect(og).connect(g); o.start(t); o.stop(t + 1.9); });
}
const scallop = 'M13 76' + ' q3.375 4 6.75 0'.repeat(8);
const svgFor = (c) => `<svg viewBox="0 0 80 ${178 + c.cl}" style="--tint:${c.tint}">
<line class="cmx-line" x1="40" y1="4" x2="40" y2="${c.cl + 33}"/><circle class="cmx-ring" cx="40" cy="4" r="3"/>
<g transform="translate(0 ${c.cl})"><ellipse class="cmx-metal" cx="40" cy="33" rx="5" ry="2.6"/>
<path class="cmx-glass" d="M16 76C16 44 27 34 40 34s24 10 24 42Z"/><path d="M22 66C22 53 27 45 34 41" fill="none" stroke="#fff" stroke-opacity=".7" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="42" cy="58" r="6.5" fill="${c.tint}" opacity=".75"/><path d="M24 70q4-4 8 0t8 0 8 0 8 0" fill="none" stroke="${c.tint}" stroke-width="1.2" opacity=".6"/>
<path class="cmx-rim" d="${scallop}" fill="none"/><line class="cmx-line" x1="40" y1="38" x2="40" y2="76"/><ellipse class="cmx-metal" cx="40" cy="78" rx="4" ry="2.6"/>
<g class="tz"><line class="cmx-line" x1="40" y1="80" x2="40" y2="106"/><path d="M31 106H49V162L40 156 31 162Z" fill="${c.tint}" opacity=".9"/><rect x="31" y="106" width="18" height="5" fill="#000" opacity=".14"/>
<line x1="40" y1="116" x2="40" y2="146" stroke="#fff" stroke-opacity=".75" stroke-width="1.2" stroke-dasharray="3 2.6"/></g></g></svg>`;
export function mountChimes(el, o = {}) {
css('cmx-css', `.cmx{position:absolute;top:0;left:0;right:0;height:0;pointer-events:none;z-index:5;--g:rgba(150,200,235,.3);--s:rgba(70,110,150,.6);--m:#9a8f7a}
:root[data-theme="dark"] .cmx{--g:rgba(160,200,255,.16);--s:rgba(190,215,255,.5);--m:#b9ad95}
.cmx-c{position:absolute;top:0;width:44px}.cmx-c svg{display:block;width:100%;height:auto;overflow:visible;transform-origin:50% 0}
.cmx-line{stroke:var(--m);stroke-width:.9;opacity:.85}.cmx-ring{fill:none;stroke:var(--m);stroke-width:1.3}.cmx-glass{fill:var(--g);stroke:var(--s);stroke-width:1.2}
.cmx-rim{stroke:var(--s);stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}.cmx-metal{fill:var(--m)}`);
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
const n = clamp(o.count ?? 4, 1, 4), sound = o.sound !== false;
const box = document.createElement('div'); box.className = 'cmx'; box.setAttribute('aria-hidden', 'true');
box.innerHTML = SPEC.slice(0, n).map((c, i) => `<div class="cmx-c" style="left:${((i + .5) / n * 100).toFixed(1)}%;margin-left:-22px" data-t="${c.t}" data-f="${c.f}">${svgFor(c)}</div>`).join('');
el.append(box);
const cs = [...box.children].map((d) => ({ el: d, svg: d.querySelector('svg'), tz: d.querySelector('.tz'), k: (2 * Math.PI / +d.dataset.t) ** 2, f: +d.dataset.f,
th: (Math.random() - .5) * .1, w: 0, ph: 0, cool: 0, inside: false, seed: Math.random() * 100 }));
const ac = new AbortController(), signal = ac.signal;
if (sound && (!actx || actx.state !== 'running')) onUnlock();
if (sound) document.addEventListener('visibilitychange', () => { if (!document.hidden && actx && actx.state !== 'running') onUnlock(); }, { signal });
const say = (f, v) => { if (sound) ding(f, v); };
let raf = 0, last = 0, t = 0, gust = 0, nextGust = 6, visible = true, lx = null;
const run = () => { if (!raf && !document.hidden && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } };
addEventListener('pointermove', (e) => { // brushing the mouse past gives a nudge
if (lx !== null && e.pointerType === 'mouse' && visible) {
const dx = e.clientX - lx;
for (const c of cs) {
const r = c.el.getBoundingClientRect(), over = e.clientX > r.left - 6 && e.clientX < r.right + 6 && e.clientY > r.top && e.clientY < r.bottom;
if (over && Math.abs(dx) > 1.5) { c.w += clamp(dx * .02, -.7, .7) * (.4 + (e.clientY - r.top) / r.height); run(); }
if (over && !c.inside) { if (Math.abs(c.w) < .5) c.w += (dx >= 0 ? 1 : -1) * .55; say(c.f, .06); c.cool = .35; run(); } // a touch always rings
c.inside = over;
}
}
lx = e.clientX;
}, { signal, passive: true });
const step = (c, dt) => {
const wind = .07 * c.k * (Math.sin(t * .9 + c.seed) + .6 * Math.sin(t * 2.3 + c.seed * 1.7)) * (.35 + gust);
c.w += (-c.k * Math.sin(c.th) - .32 * c.w + wind) * dt; c.th = clamp(c.th + c.w * dt, -1.3, 1.3); c.cool -= dt;
if (c.cool <= 0 && Math.abs(c.w) > 1.1) { say(c.f, Math.abs(c.w) * .035); c.cool = .3; }
};
function frame(now) {
const el2 = now - last;
if (el2 < 30 && !cs.some((c) => Math.abs(c.w) > .25)) { raf = requestAnimationFrame(frame); return; } // nearly still: ~30 fps
const dt = Math.min(.04, el2 / 1000 || .016); last = now; t += dt;
nextGust -= dt; if (nextGust <= 0) { gust = 1.6; nextGust = 8 + Math.random() * 8; } gust *= Math.pow(.5, dt / 1.2);
for (const c of cs) {
step(c, dt / 2); step(c, dt / 2);
c.ph += (clamp(-c.w * .22, -.7, .7) - c.ph) * Math.min(1, dt * 8); // the paper tag swings a little behind
const a = (c.th * 57.2958).toFixed(2), p = (c.ph * 57.2958).toFixed(2);
if (a !== c.sa) { c.sa = a; c.svg.style.transform = `rotate(${a}deg)`; }
if (p !== c.sp) { c.sp = p; c.tz.setAttribute('transform', `rotate(${p} 40 80)`); }
}
raf = !document.hidden && visible ? requestAnimationFrame(frame) : 0;
}
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) run(); }, { rootMargin: '80px' }); io.observe(el);
let timer = 0;
if (o.random !== false) { // now and then a little breeze rings one on its own
const breeze = () => {
if (!document.hidden && visible) { const c = cs[Math.floor(Math.random() * cs.length)]; c.w += (Math.random() < .5 ? -1 : 1) * (.9 + Math.random() * .6); say(c.f, .05); c.cool = .4; run(); }
timer = setTimeout(breeze, 6000 + Math.random() * 9000);
};
timer = setTimeout(breeze, 2500 + Math.random() * 2000);
}
document.addEventListener('visibilitychange', run, { signal });
run();
return () => { ac.abort(); io.disconnect(); clearTimeout(timer); cancelAnimationFrame(raf); box.remove(); };
}
```
## Framework wrappers
### React: `Chimes.jsx`
```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountChimes } from './chimes.js';
export default function Chimes({ options = {}, className, style }) {
const ref = useRef(null);
useEffect(() => mountChimes(ref.current, options), []); // the engine returns its own cleanup
return <div ref={ref} className={className} style={style} />;
}
```
### Vue: `Chimes.vue`
```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountChimes } from './chimes.js';
const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountChimes(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>
<template><div ref="root"></div></template>
```
### Svelte: `Chimes.svelte`
```svelte
<script>
import { onMount } from 'svelte';
import { mountChimes } from './chimes.js';
export let options = {};
export let style = '';
let klass = '';
export { klass as class };
let root;
onMount(() => mountChimes(root, options)); // returning the cleanup from onMount stops it on destroy
</script>
<div bind:this={root} class={klass} {style}></div>
```
### Solid: `Chimes.jsx`
```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountChimes } from './chimes.js';
export default function Chimes(props) {
let root;
onMount(() => onCleanup(mountChimes(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)
chimes.js9 KB
// Japanese glass wind chimes (furin): a little pendulum physics, a breeze now and then, a nudge when the mouse brushes them, and a soft glass "ting".
// mountChimes(el, options) -> stop() Hangs from the top edge of `el` (give `el` a height of about 220px). No dependencies.
//
// options (all optional):
// sound true = glass ting. Browsers only allow sound after the visitor's first click, tap or key press (see "Sound" below) default true
// random true = a breeze rings one now and then default true
// count how many chimes (1 to 4) default 4
//
// Sound: the audio engine is shared by every chime on the page and lives outside the component. It wakes up on the first real click / tap / key press,
// stays awake while you move between pages, and never queues sounds while it sleeps (so nothing bursts out later).
const SPEC = [
{ tint: '#7fb7e6', cl: 0, t: 2.7, f: 2093 }, { tint: '#e58fa8', cl: 38, t: 2.2, f: 2349 },
{ tint: '#9ccf8a', cl: 16, t: 3.0, f: 1760 }, { tint: '#c9a7f0', cl: 54, t: 2.4, f: 2637 },
];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };
// ── shared audio engine ───────────────────────────────────────────────────────
let actx = null, unlockOn = false;
const UNLOCK = ['pointerdown', 'pointerup', 'mousedown', 'touchend', 'keydown', 'click']; // every event a browser accepts as "the visitor did something"
function unlock() {
try {
if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
if (actx.state !== 'running') {
const s = actx.createBufferSource(); s.buffer = actx.createBuffer(1, 1, 22050); s.connect(actx.destination); s.start(0); // silent blip: wakes iOS / Safari
actx.resume?.();
}
if (actx.state === 'running') offUnlock(); else actx.onstatechange = () => (actx.state === 'running' ? offUnlock() : onUnlock());
} catch {}
}
function onUnlock() { if (unlockOn) return; unlockOn = true; UNLOCK.forEach((e) => addEventListener(e, unlock, { passive: true, capture: true })); }
function offUnlock() { unlockOn = false; UNLOCK.forEach((e) => removeEventListener(e, unlock, { capture: true })); }
function ding(f, v) {
if (!actx) return;
if (actx.state !== 'running') { actx.resume?.(); return; } // still asleep: stay quiet
const t = actx.currentTime, g = actx.createGain();
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(Math.min(.1, v), t + .004); g.gain.exponentialRampToValueAtTime(.0001, t + 1.8); g.connect(actx.destination);
[1, 2.76].forEach((m, i) => { const o = actx.createOscillator(); o.frequency.value = f * m; o.type = 'sine'; const og = actx.createGain(); og.gain.value = i ? .25 : 1; o.connect(og).connect(g); o.start(t); o.stop(t + 1.9); });
}
const scallop = 'M13 76' + ' q3.375 4 6.75 0'.repeat(8);
const svgFor = (c) => `<svg viewBox="0 0 80 ${178 + c.cl}" style="--tint:${c.tint}">
<line class="cmx-line" x1="40" y1="4" x2="40" y2="${c.cl + 33}"/><circle class="cmx-ring" cx="40" cy="4" r="3"/>
<g transform="translate(0 ${c.cl})"><ellipse class="cmx-metal" cx="40" cy="33" rx="5" ry="2.6"/>
<path class="cmx-glass" d="M16 76C16 44 27 34 40 34s24 10 24 42Z"/><path d="M22 66C22 53 27 45 34 41" fill="none" stroke="#fff" stroke-opacity=".7" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="42" cy="58" r="6.5" fill="${c.tint}" opacity=".75"/><path d="M24 70q4-4 8 0t8 0 8 0 8 0" fill="none" stroke="${c.tint}" stroke-width="1.2" opacity=".6"/>
<path class="cmx-rim" d="${scallop}" fill="none"/><line class="cmx-line" x1="40" y1="38" x2="40" y2="76"/><ellipse class="cmx-metal" cx="40" cy="78" rx="4" ry="2.6"/>
<g class="tz"><line class="cmx-line" x1="40" y1="80" x2="40" y2="106"/><path d="M31 106H49V162L40 156 31 162Z" fill="${c.tint}" opacity=".9"/><rect x="31" y="106" width="18" height="5" fill="#000" opacity=".14"/>
<line x1="40" y1="116" x2="40" y2="146" stroke="#fff" stroke-opacity=".75" stroke-width="1.2" stroke-dasharray="3 2.6"/></g></g></svg>`;
export function mountChimes(el, o = {}) {
css('cmx-css', `.cmx{position:absolute;top:0;left:0;right:0;height:0;pointer-events:none;z-index:5;--g:rgba(150,200,235,.3);--s:rgba(70,110,150,.6);--m:#9a8f7a}
:root[data-theme="dark"] .cmx{--g:rgba(160,200,255,.16);--s:rgba(190,215,255,.5);--m:#b9ad95}
.cmx-c{position:absolute;top:0;width:44px}.cmx-c svg{display:block;width:100%;height:auto;overflow:visible;transform-origin:50% 0}
.cmx-line{stroke:var(--m);stroke-width:.9;opacity:.85}.cmx-ring{fill:none;stroke:var(--m);stroke-width:1.3}.cmx-glass{fill:var(--g);stroke:var(--s);stroke-width:1.2}
.cmx-rim{stroke:var(--s);stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}.cmx-metal{fill:var(--m)}`);
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
const n = clamp(o.count ?? 4, 1, 4), sound = o.sound !== false;
const box = document.createElement('div'); box.className = 'cmx'; box.setAttribute('aria-hidden', 'true');
box.innerHTML = SPEC.slice(0, n).map((c, i) => `<div class="cmx-c" style="left:${((i + .5) / n * 100).toFixed(1)}%;margin-left:-22px" data-t="${c.t}" data-f="${c.f}">${svgFor(c)}</div>`).join('');
el.append(box);
const cs = [...box.children].map((d) => ({ el: d, svg: d.querySelector('svg'), tz: d.querySelector('.tz'), k: (2 * Math.PI / +d.dataset.t) ** 2, f: +d.dataset.f,
th: (Math.random() - .5) * .1, w: 0, ph: 0, cool: 0, inside: false, seed: Math.random() * 100 }));
const ac = new AbortController(), signal = ac.signal;
if (sound && (!actx || actx.state !== 'running')) onUnlock();
if (sound) document.addEventListener('visibilitychange', () => { if (!document.hidden && actx && actx.state !== 'running') onUnlock(); }, { signal });
const say = (f, v) => { if (sound) ding(f, v); };
let raf = 0, last = 0, t = 0, gust = 0, nextGust = 6, visible = true, lx = null;
const run = () => { if (!raf && !document.hidden && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } };
addEventListener('pointermove', (e) => { // brushing the mouse past gives a nudge
if (lx !== null && e.pointerType === 'mouse' && visible) {
const dx = e.clientX - lx;
for (const c of cs) {
const r = c.el.getBoundingClientRect(), over = e.clientX > r.left - 6 && e.clientX < r.right + 6 && e.clientY > r.top && e.clientY < r.bottom;
if (over && Math.abs(dx) > 1.5) { c.w += clamp(dx * .02, -.7, .7) * (.4 + (e.clientY - r.top) / r.height); run(); }
if (over && !c.inside) { if (Math.abs(c.w) < .5) c.w += (dx >= 0 ? 1 : -1) * .55; say(c.f, .06); c.cool = .35; run(); } // a touch always rings
c.inside = over;
}
}
lx = e.clientX;
}, { signal, passive: true });
const step = (c, dt) => {
const wind = .07 * c.k * (Math.sin(t * .9 + c.seed) + .6 * Math.sin(t * 2.3 + c.seed * 1.7)) * (.35 + gust);
c.w += (-c.k * Math.sin(c.th) - .32 * c.w + wind) * dt; c.th = clamp(c.th + c.w * dt, -1.3, 1.3); c.cool -= dt;
if (c.cool <= 0 && Math.abs(c.w) > 1.1) { say(c.f, Math.abs(c.w) * .035); c.cool = .3; }
};
function frame(now) {
const el2 = now - last;
if (el2 < 30 && !cs.some((c) => Math.abs(c.w) > .25)) { raf = requestAnimationFrame(frame); return; } // nearly still: ~30 fps
const dt = Math.min(.04, el2 / 1000 || .016); last = now; t += dt;
nextGust -= dt; if (nextGust <= 0) { gust = 1.6; nextGust = 8 + Math.random() * 8; } gust *= Math.pow(.5, dt / 1.2);
for (const c of cs) {
step(c, dt / 2); step(c, dt / 2);
c.ph += (clamp(-c.w * .22, -.7, .7) - c.ph) * Math.min(1, dt * 8); // the paper tag swings a little behind
const a = (c.th * 57.2958).toFixed(2), p = (c.ph * 57.2958).toFixed(2);
if (a !== c.sa) { c.sa = a; c.svg.style.transform = `rotate(${a}deg)`; }
if (p !== c.sp) { c.sp = p; c.tz.setAttribute('transform', `rotate(${p} 40 80)`); }
}
raf = !document.hidden && visible ? requestAnimationFrame(frame) : 0;
}
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) run(); }, { rootMargin: '80px' }); io.observe(el);
let timer = 0;
if (o.random !== false) { // now and then a little breeze rings one on its own
const breeze = () => {
if (!document.hidden && visible) { const c = cs[Math.floor(Math.random() * cs.length)]; c.w += (Math.random() < .5 ? -1 : 1) * (.9 + Math.random() * .6); say(c.f, .05); c.cool = .4; run(); }
timer = setTimeout(breeze, 6000 + Math.random() * 9000);
};
timer = setTimeout(breeze, 2500 + Math.random() * 2000);
}
document.addEventListener('visibilitychange', run, { signal });
run();
return () => { ac.abort(); io.disconnect(); clearTimeout(timer); cancelAnimationFrame(raf); box.remove(); };
}Save it in your project (for example src/chimes/). 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:260px"></div>
<script type="module">
import { mountChimes } from './chimes.js';
const stop = mountChimes(document.getElementById('stage'), { sound: true, random: true });
// 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 Chimes.jsx next to the engine:
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountChimes } from './chimes.js';
export default function Chimes({ options = {}, className, style }) {
const ref = useRef(null);
useEffect(() => mountChimes(ref.current, options), []); // the engine returns its own cleanup
return <div ref={ref} className={className} style={style} />;
}Then use it:
import Chimes from './Chimes';
export default function Page() {
return <Chimes options={{ sound: true, random: true }} style={{ height: '260px' }} />;
}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 Chimes.vue next to the engine:
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountChimes } from './chimes.js';
const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountChimes(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>
<template><div ref="root"></div></template>Then use it:
<script setup>
import Chimes from './Chimes.vue';
</script>
<template>
<Chimes :options="{ sound: true, random: true }" style="height:260px" />
</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 Chimes.svelte next to the engine:
<script>
import { onMount } from 'svelte';
import { mountChimes } from './chimes.js';
export let options = {};
export let style = '';
let klass = '';
export { klass as class };
let root;
onMount(() => mountChimes(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 Chimes from './Chimes.svelte';
</script>
<Chimes options={{ sound: true, random: true }} style="height:260px" />Works in Solid and SolidStart. Starts in `onMount`, so it never runs on the server.
Create Chimes.jsx next to the engine:
import { onMount, onCleanup } from 'solid-js';
import { mountChimes } from './chimes.js';
export default function Chimes(props) {
let root;
onMount(() => onCleanup(mountChimes(root, props.options || {})));
return <div ref={root} class={props.class} style={props.style} />;
}Then use it:
import Chimes from './Chimes';
export default function Page() {
return <Chimes options={{ sound: true, random: true }} style={{ height: '260px' }} />;
}Four glass furin hang from the top of the box. They sway in a breeze, swing when the mouse brushes past and ring with a soft glass “ting”. Click once anywhere to wake the sound (browsers require that), then brush them.
How it works
- Each chime is a pendulum: an angle and a speed with gravity, damping and a wind force. The little paper tag swings a step behind.
- The mouse gives a nudge, and a first touch always rings, even a light one.
- Sound is synthesised with the Web Audio API (two sine tones, no audio files).
- Every few seconds a breeze rings one by itself. It stops simulating when scrolled off screen.
Options
Every option is optional. These are the defaults.
| Option | Default | What it does |
|---|---|---|
sound | true | glass ting on/off |
random | true | a breeze rings one now and then |
count | 4 | how many chimes, 1 to 4 |
Tips
- Sound needs one real click, tap or key press before the browser allows it. That is a browser rule, nothing code can bypass.
- Hang them from a header: give the container the header height and
overflow: visible. sound: falsefor a silent, purely visual version.