← Playground

Magic quote

Words that melt in out of a warm glow

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-quote in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add words that melt in out of a glow to my site".

Download SKILL.md

SKILL.mdpreview
---
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} />;
}
```

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)

quote.js6 KB
// 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 = ''; };
}

Save it in your project (for example src/quote/). 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"></div>

<script type="module">
  import { mountQuote } from './quote.js';

  const stop = mountQuote(document.getElementById('stage'), { quotes: [{ text: 'Slow is smooth, and smooth is fast.', by: 'Old saying' }] });
  // when you are done (for example when a page closes): stop();
</script>

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.

How it works

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
quotes3 samples[{ text, by }, ...] your own list
magictruefalse = no animation
buttontruefalse = hide the “another quote” button

Tips

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