---
name: nikuma-quote
description: Add a quote block whose words melt in one by one with a glow and sparkles, with a button for another quote. Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---

# Magic quote

A quote where every word melts in out of a warm glow, a few sparkles drift off, and the author signs last in script handwriting. The sparkle button swaps in another quote.

One dependency-free file. `mountQuote(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: words that melt in out of a warm glow. 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/quote/`), 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 { mountQuote } from './quote.js';
const stop = mountQuote(document.getElementById('stage'), { quotes: [{ text: 'Slow is smooth, and smooth is fast.', by: 'Old saying' }] });
// stop();  // when the element is removed
```

## Options (all optional)

| Option | Default | What it does |
|---|---|---|
| `quotes` | `3 samples` | `[{ text, by }, ...]` your own list |
| `magic` | `true` | false = no animation |
| `button` | `true` | false = hide the "another quote" button |

## Rules

- The container is filled by the component (`el.innerHTML`): give it an empty element.
- Keep quotes short (one or two lines) for the best effect.
- 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

### `quote.js`

```js
// A quote whose words melt in one by one out of a warm glow, with a few sparkles. The sparkle button shows another quote.
// mountQuote(el, options) -> stop()
//
// options:
//   quotes  [{ text, by }, ...]                      default: three short ones
//   magic   false = the words just appear (also used when the visitor prefers reduced motion)
//   button  false = hide the "another quote" button  default true
const DEFAULT = [
  { text: 'Slow is smooth, and smooth is fast.', by: 'Old saying' },
  { text: 'Make it work, make it right, make it fast.', by: 'Kent Beck' },
  { text: 'Simplicity is the ultimate sophistication.', by: 'Leonardo da Vinci' },
];
const rand = (a, b) => a + Math.random() * (b - a);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

export function mountQuote(el, o = {}) {
  css('qtx-css', `.qtx{position:relative;display:flex;flex-direction:column;align-items:center;gap:14px;text-align:center;padding:20px}
.qtx-fig{position:relative;margin:0;max-width:34ch}
.qtx-glow{position:absolute;inset:-34% -28%;z-index:-1;border-radius:50%;pointer-events:none;opacity:0;transition:opacity 1.6s;background:radial-gradient(closest-side,rgba(170,140,255,.15),rgba(255,200,120,.1) 55%,transparent 76%)}
.qtx-fig.on .qtx-glow{opacity:1;animation:qtx-aura 4.2s ease-in-out infinite alternate}@keyframes qtx-aura{to{transform:scale(1.08)}}
.qtx-text{margin:0;min-height:2.9em;font:italic 300 clamp(1.3rem,3.6vw,1.8rem)/1.4 Georgia,'Times New Roman',serif;letter-spacing:-.01em;text-wrap:balance}
.qtx-by{margin-top:6px;font:400 2.1rem/1 'Mrs Saint Delafield',cursive;opacity:.65}
.qtx-w{display:inline-block;opacity:0;animation:qtx-in 1.05s cubic-bezier(.2,.7,.1,1) calc(var(--i) * 80ms + 120ms) forwards}
@keyframes qtx-in{0%{opacity:0;filter:blur(10px);transform:translateY(.5em) scale(.9);text-shadow:0 0 0 transparent}45%{text-shadow:0 0 20px rgba(255,205,110,.95),0 0 5px rgba(255,255,255,.85)}100%{opacity:1;filter:blur(0);transform:none;text-shadow:0 0 0 transparent}}
.out .qtx-w{animation:qtx-out .7s ease-in calc(var(--i) * 22ms) forwards}@keyframes qtx-out{from{opacity:1}to{opacity:0;filter:blur(8px);transform:translateY(-.6em)}}
.qtx-spk{position:absolute;pointer-events:none;background:#f3b93f;clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%);filter:drop-shadow(0 0 3px rgba(255,210,100,.9))}
.qtx-more{display:grid;place-items:center;width:34px;height:34px;border:0;border-radius:50%;background:rgba(127,127,127,.14);color:inherit;opacity:.7;cursor:pointer;transition:transform .7s cubic-bezier(.2,.7,.1,1),opacity .3s}
.qtx-more:hover{opacity:1;transform:rotate(72deg) scale(1.08)}.qtx-more:disabled{cursor:default}
.qtx.calm .qtx-w{animation:none;opacity:1}.qtx.calm .qtx-glow,.qtx.calm .qtx-spk{display:none}
@media(prefers-reduced-motion:reduce){.qtx:not(.force) .qtx-w{animation:none;opacity:1}.qtx:not(.force) .qtx-fig.on .qtx-glow{animation:none}}`);
  const list = o.quotes?.length ? o.quotes : DEFAULT;
  const calm = o.magic === false || (!o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches);
  el.innerHTML = `<div class="qtx${calm ? ' calm' : ''}${o.ignoreMotionPref ? ' force' : ''}"><figure class="qtx-fig"><span class="qtx-glow" aria-hidden="true"></span><blockquote class="qtx-text"></blockquote><figcaption class="qtx-by"></figcaption></figure>${o.button === false ? '' : '<button class="qtx-more" type="button" aria-label="Show another quote"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M12 2l2.2 6.3L21 10l-6.8 1.7L12 18l-2.2-6.3L3 10l6.8-1.7z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z" opacity=".7"/></svg></button>'}</div>`;
  const fig = el.querySelector('.qtx-fig'), text = el.querySelector('.qtx-text'), by = el.querySelector('.qtx-by'), btn = el.querySelector('.qtx-more');
  const timers = []; let last = -1, io;
  const later = (fn, ms) => timers.push(setTimeout(fn, ms));
  const roll = () => { let i; do { i = Math.floor(Math.random() * list.length); } while (list.length > 1 && i === last); last = i; return list[i]; };
  const sparkle = (x, y) => {
    const s = document.createElement('i'), z = rand(7, 13); s.className = 'qtx-spk'; s.style.cssText = `left:${x}px;top:${y}px;width:${z}px;height:${z}px`; fig.append(s);
    s.animate([{ opacity: 0, transform: 'translate(-50%,-50%) scale(.2) rotate(0)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1) rotate(45deg)', offset: .3 },
      { opacity: 0, transform: `translate(calc(-50% + ${rand(-16, 16)}px),calc(-50% + ${rand(8, 26)}px)) scale(.1) rotate(140deg)` }], { duration: rand(800, 1300), easing: 'ease-out' }).onfinish = () => s.remove();
  };
  const show = (q) => {
    const words = q.text.split(' '), name = ('— ' + q.by).split(' ');
    text.classList.remove('out'); by.classList.remove('out');
    text.innerHTML = words.map((w, i) => `<span class="qtx-w" style="--i:${i}">${esc(w)}</span>`).join(' ');
    by.innerHTML = name.map((w, i) => `<span class="qtx-w" style="--i:${words.length + 4 + i}">${esc(w)}</span>`).join(' ');   // the name melts in last
    fig.classList.remove('on'); void fig.offsetWidth; fig.classList.add('on');
    if (calm) return;
    const fr = fig.getBoundingClientRect();
    el.querySelectorAll('.qtx-w').forEach((w) => later(() => { const r = w.getBoundingClientRect(); for (let k = 0; k < 1 + (Math.random() < .5); k++) sparkle(r.left - fr.left + rand(0, r.width), r.top - fr.top + rand(-4, r.height * .8)); }, +w.style.getPropertyValue('--i') * 80 + 250));
  };
  const swap = () => {
    if (btn.disabled) return; btn.disabled = true; const q = roll();
    if (calm) { show(q); btn.disabled = false; return; }
    text.classList.add('out'); by.classList.add('out'); fig.classList.remove('on');
    later(() => { show(q); btn.disabled = false; }, 750);
  };
  btn?.addEventListener('click', swap);
  const first = roll();
  if (calm) show(first);
  else { io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); show(first); } }, { threshold: .6 }); io.observe(fig); }   // starts when scrolled into view
  return () => { io?.disconnect(); timers.forEach(clearTimeout); btn?.removeEventListener('click', swap); el.innerHTML = ''; };
}
```

## Framework wrappers

### React: `MagicQuote.jsx`

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

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

### Vue: `MagicQuote.vue`

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

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

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

### Svelte: `MagicQuote.svelte`

```svelte
<script>
  import { onMount } from 'svelte';
  import { mountQuote } from './quote.js';

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

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

### Solid: `MagicQuote.jsx`

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

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