← Playground

Wind chimes

Glass wind chimes with a soft ting

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

Download SKILL.md

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>

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

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
soundtrueglass ting on/off
randomtruea breeze rings one now and then
count4how many chimes, 1 to 4

Tips

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