---
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, bus = null, unlockOn = false;
const UNLOCK = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'touchstart', 'touchend', 'keydown', 'click'];
// Wakes the audio engine. Browsers only allow sound after a real click / tap / key press, so this runs on every such event until the engine is really "running".
function unlock() {
  try {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    if (!actx) {
      actx = new AC();
      // everything goes through one compressor, so a chime is clearly audible but never harsh or clipped
      bus = actx.createGain(); bus.gain.value = 1;
      const comp = actx.createDynamicsCompressor(); comp.threshold.value = -14; comp.ratio.value = 4;
      bus.connect(comp); comp.connect(actx.destination);
      actx.onstatechange = () => { if (actx.state === 'running') offUnlock(); else onUnlock(); };   // iOS can pause it again later: listen again
    }
    if (actx.state !== 'running') {
      const s = actx.createBufferSource(); s.buffer = actx.createBuffer(1, 1, 22050); s.connect(actx.destination); s.start(0);   // a silent blip: wakes iOS / Safari
      const r = actx.resume?.(); if (r && r.catch) r.catch(() => {});
    }
    if (actx.state === 'running') offUnlock();
  } catch {}
}
function onUnlock() { if (unlockOn) return; unlockOn = true; UNLOCK.forEach((e) => window.addEventListener(e, unlock, { passive: true, capture: true })); }
function offUnlock() { unlockOn = false; UNLOCK.forEach((e) => window.removeEventListener(e, unlock, { capture: true })); }
// One glass "ting": a few inharmonic partials, each fading at its own speed. v = how hard it was hit (about .02 to .09).
function play(f, v) {
  const t = actx.currentTime, peak = Math.min(.17, .045 + v * 1.1);   // gentle: a soft glass tinkle, not a bell
  [[1, 1, 3.4], [2.76, .32, 2], [5.4, .09, 1], [8.93, .02, .5]].forEach(([m, a, d], i) => {
    const o = actx.createOscillator(), g = actx.createGain();
    o.type = 'sine'; o.frequency.value = f * m * (1 + (Math.random() - .5) * .002);
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(peak * a, t + (i ? .008 : .02));   // a soft attack
    g.gain.exponentialRampToValueAtTime(0.0001, t + d);
    o.connect(g); g.connect(bus); o.start(t); o.stop(t + d + .05);
  });
}
// Rings a chime. No engine yet (the visitor has not clicked): silent. Engine made but still waking (the very click that made it, or iOS after a pause):
// ask it to wake and play only if it does within a moment, so a sound never comes out late.
function ring(f, v) {
  if (!actx || !bus) return;
  if (actx.state === 'running') return play(f, v);
  const asked = performance.now();
  try { const r = actx.resume?.(); r?.then?.(() => { if (actx.state === 'running' && performance.now() - asked < 350) play(f, v); }, () => {}); } catch {}
}

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 && document.documentElement.dataset.chimes !== 'off') ring(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} />;
}
```
