@tsogtoodev/particle-glock
v0.2.1
Published
Turn any SVG into an interactive particle mark for Vue 3 — assembles from a scattered cloud, repels from the cursor, fires comet bullets on click, morphs between shapes, and renders HTTP status codes for error pages.
Maintainers
Readme
@tsogtoodev/particle-glock
An interactive particle-mark for any SVG logo, for Vue 3.
Every opaque pixel of your SVG becomes a particle. The mark flies in from a scattered cloud, repels away from the cursor, fires comet "bullets" on click (with an impact shockwave), auto-shoots on an idle timer, and can morph between two shapes. No WebGL, no dependencies beyond Vue.
Particles render on a full-viewport, click-through canvas appended to
<body> — never inside your layout — so no ancestor's overflow, size, or box
can ever crop the effect. The mark is drawn at the host element's on-screen
position and follows it as the page scrolls or resizes.
Install
npm i @tsogtoodev/particle-glockUsage
<script setup lang="ts">
import { ParticleLogo } from '@tsogtoodev/particle-glock';
import logo from './logo.svg?raw'; // or a URL string, or inline markup
</script>
<template>
<ParticleLogo :svg="logo" :width="37" :height="29" color="#1a191d" idle />
</template>svg accepts inline SVG markup, a data: URI, or a URL to an .svg
file (fetched at runtime). With Vite, import logo from './logo.svg?raw' gives
you the markup string directly.
Register globally (optional)
import { createApp } from 'vue';
import ParticleLogoPlugin from '@tsogtoodev/particle-glock';
createApp(App).use(ParticleLogoPlugin); // <ParticleLogo> available everywhereProps
| Prop | Type | Default | Description |
| ---------------------- | ------------------------------------------- | -------------- | ----------- |
| svg (required) | string | — | Inline markup, data URI, or URL to the mark. |
| altSvg | string | — | A second shape to morph into (contain-fit, centered). |
| width / height | number | SVG intrinsic | Display size in CSS px. |
| color | string (any CSS color) | inherited color | Ink color of the particles. |
| padding | number | 60 | Minimum cursor reach (px) beyond the mark. The hover zone is always at least as large as the repulsion field, so raising this only widens it further. Particles are never clipped. |
| hover | boolean | auto | Repel particles from the cursor. Off by default on touch devices. |
| shootOnClick | boolean | true | Fire a bullet at the click point. |
| idle | boolean \| { minMs?, maxMs? } | false | Auto-shoot on a random interval (default 12–28s). |
| autostart | boolean | true | Assemble on mount. |
| params | Partial<AssemblyParams> | — | Engine tuning (see below). |
| respectReducedMotion | boolean | true | Render a static SVG when the user prefers reduced motion. |
| maxDpr | number | 2 | Clamp device pixel ratio. |
| quality | 'auto' \| 'high' \| 'low' | 'auto' | Performance profile — see Performance. |
| zIndex | number | 2147483000 | z-index of the click-through overlay canvas. |
| sound | boolean \| { volume?: number } | false | Play synthesized UI sounds on interaction (see below). |
| as | string | 'span' | Wrapper tag (e.g. 'a', 'button'). |
Events
@assembled · @impact · @ready
Exposed methods (via template ref)
<ParticleLogo ref="logo" :svg="mark" :alt-svg="arrow" />logo.value.shoot(clientX?, clientY?); // fire a bullet (defaults to mark center)
logo.value.morphTo(1); // 0 = mark, 1 = alt shape
logo.value.toggleMorph();
logo.value.setColor('#e5484d');Engine tuning (params)
| Key | Default | Meaning |
| -------------- | ------- | ------- |
| assembleMs | 1100 | Fly-in duration per particle. |
| staggerMs | 700 | Max random start delay (the staggered gather). |
| gatherSpread | 2.6 | Scatter radius, as a multiple of mark size. Affects the fly-in only. |
| followLag | 10 | How snappily particles chase a moved origin. |
| jitter | 0.65 | Sub-pixel randomisation of resting positions (device px). See below. |
| maxParticles | 40000 | Hard cap on particle count. See Performance. |
| streakDetail | 3 | Nested layers per motion streak (3 = comet tail, 1 = single line). |
About jitter — particles are sampled one per source pixel, so they rest on
a perfectly regular lattice. Warping that lattice (cursor repulsion, blasts)
makes neighbours fall in and out of phase, which shows up as concentric moiré
rings. Jittering each resting position by roughly the lattice spacing breaks the
regularity and roughly halves the ringing; past ~0.65 there's no further gain,
only a softer shape. Set 0 for a mathematically exact lattice. The value
scales automatically with the sampling stride, so it always stays proportional
to the actual particle spacing.
Performance
Particles are sampled one per source pixel, so a full-page number can reach 70k+ particles — far more than a phone can integrate at 60fps. Two things keep that in check:
A particle budget. When a mark exceeds params.maxParticles, the sampler
takes every Nth pixel on both axes and draws each dot N times larger — coverage
looks the same, the work drops by N². Budgets are set so N stays at 1–2 for
typical marks; past that the dots get big enough to read as blocks. Combined
with grouping particles by index instead of re-sweeping the array once per alpha
bucket, per-frame work falls roughly 7x on desktop and 12x on mobile.
Resolution is deliberately not part of the trade. A settled mark is a blit of the source raster, so lowering the device pixel ratio would make the resting state — the thing people look at most — visibly soft. Particle count is the cheap lever; sharpness is not.
Cheaper motion streaks. During a blast every particle is moving fast, so
every particle takes the streak path — at the default 3 nested layers that is 6
Path2D ops per particle per frame, which profiling showed to be the single
biggest cost in the heavy case. params.streakDetail: 1 draws one line instead.
A device profile. quality decides the budget, the pixel-ratio cap, streak
detail, and whether the cursor field runs at all:
| quality | Budget | maxDpr | Cursor field | Streak layers |
| --------- | ------- | -------- | ------------ | ------------- |
| 'high' | 40 000 | 2 | on | 3 |
| 'low' | 8 000 | 2 | off | 1 |
| 'auto' | picks 'low' on a coarse pointer, or 4 or fewer CPU cores | | | |
Measured on an iPhone 16e simulator, error page (code + headline), firing continuously so particles never settle:
| | before | after | | --- | --- | --- | | sustained | 12 fps | 56 fps | | worst 1s average | 11 fps | 37 fps | | peak frame time | 102 ms | 31 ms |
At rest it is 60 fps in both cases — a settled mark is a single cached-raster blit and the animation loop stops entirely.
'auto' is the default. A touch device has no hovering cursor, so the repulsion
field there costs per-particle maths every frame and can never be seen — hence
off. Any explicit hover, maxDpr or params.maxParticles always wins over
the profile:
<!-- force the full-fat version everywhere -->
<ParticleLogo :svg="mark" quality="high" />
<!-- or tune the budget yourself -->
<ParticleLogo :svg="mark" :params="{ maxParticles: 8000 }" />Other costs worth knowing: the animation loop stops entirely once everything settles (a settled mark is a single cached-raster blit, not a particle sweep), and it stops when the cursor leaves the field's reach rather than spinning.
Composable
For full control, skip the component:
import { ref } from 'vue';
import { useParticleLogo } from '@tsogtoodev/particle-glock';
const host = ref<HTMLElement | null>(null);
const { shoot, morphTo, setColor, ready } = useParticleLogo(host, () => ({
svg: mark,
altSvg: arrow,
width: 37,
height: 29,
idle: { minMs: 8000, maxMs: 20000 },
}));<div ref="host" />Framework-agnostic core
The engine has no Vue dependency. Use it in vanilla JS or other frameworks:
import { mountParticleLogo } from '@tsogtoodev/particle-glock';
const instance = await mountParticleLogo(document.getElementById('logo')!, {
svg: markString,
width: 37,
height: 29,
color: '#1a191d',
});
// instance.shoot() / .morphTo(1) / .setColor() / .dispose()Lower-level still: createAssembly(displayCanvas, sourceCanvas, opts) returns the
raw controller (start / setOrigin / setPointer / shoot / setAltShape /
morphTo / setInk / dispose). See the exported types.
Error pages (status codes)
<ParticleStatus> renders an HTTP status code as particles — built for 404 /
500 pages. Digit glyphs ship with the package, so no asset wiring is needed:
<script setup>
import { ParticleStatus } from '@tsogtoodev/particle-glock';
</script>
<template>
<ParticleStatus :code="404" text="Page not found" />
<ParticleStatus :code="503" text="Серверийн алдаа" :text-size="38" sound />
</template>Props
| Prop | Type | Default | Description |
| -------------- | ----------------------------------- | ---------------- | ----------- |
| code (req) | string \| number | — | e.g. 404, "500". |
| text | string | — | Headline below the code, in the same glyphs. |
| textSize | number | 34 | Cap height of the headline in CSS px. |
| gap | number | 26 | Space between code and headline in CSS px. |
| preset | StatusPreset | picked from code | See below. |
| height | number | 160 | Cap height of the code in CSS px; width follows digit count. |
| color | string | inherited | Ink color. |
| hover · shootOnClick · idle · sound · respectReducedMotion · zIndex | | | Same as <ParticleLogo>. |
height and textSize are both cap heights — the box grows to fit
descenders without changing the perceived size. The headline is drawn from the
same glyph outlines as the code, so the two read as one typeface at two sizes,
with the same particle effects and preset.
Exposes shoot() and setColor(); emits @assembled / @impact.
Presets
| Preset | Feel | Auto-applied to |
| ---------- | ----------------------------------------------- | --------------- |
| drift | Slow, wide, weightless gather — "lost in space" | 4xx (e.g. 404) |
| snap | Fast and tight — "denied" | 401, 403, 429 |
| shatter | Assembles, then a comet blows it apart | 5xx |
| assemble | The standard logo fly-in | — |
Omit preset and one is chosen from the code; pass it explicitly to override.
Glyph coverage
Glyph outlines are extracted from a real typeface — Geologica (SIL Open Font License, see THIRD-PARTY-NOTICES.md) — so proportions, side bearings and advance widths are the font's own rather than approximations.
The built-in set covers 0–9, A–Z, all 35 Mongolian Cyrillic
letters (А–Я, including Ө and Ү) and basic punctuation
(. , ! ? - : ' / and space). It is caps-only — input is upper-cased for you,
so "Page not found" and "Серверийн алдаа" both work.
Characters outside the set are dropped rather than thrown on the text
prop, so an unexpected em dash or emoji can't break an error page. Check
coverage up front if you'd rather handle it yourself:
import { canRenderText, missingGlyphs, sanitizeGlyphText } from '@tsogtoodev/particle-glock';
canRenderText('Хуудас олдсонгүй'); // true
missingGlyphs('Oops — 404'); // ['—']
sanitizeGlyphText('Oops — 404'); // 'OOPS 404'Getting the markup yourself
Glyphs are built in, so nothing needs downloading. To drive <ParticleLogo>
directly (or to render a code or headline anywhere else):
import { statusCodeSvg, glyphTextSvg } from '@tsogtoodev/particle-glock';
const code = statusCodeSvg(404);
const line = glyphTextSvg('Хуудас олдсонгүй');If you want the codes as standalone .svg files (for design tools or non-JS
use), generate them into out/status/:
npm run gen:status -- 404 500 418Using a different typeface
The glyph data in src/core/glyph-data.ts is generated. Point the script at any
font you have the rights to use and it re-derives the whole set:
npm run gen:glyphs -- path/to/YourFont.ttfIt normalises to cap height, keeps the font's own advance widths, and fails loudly if a required character is missing. Update THIRD-PARTY-NOTICES.md to match the new font's license.
Sound
Set sound to add audio feedback. Every sound is synthesized live with the
Web Audio API — oscillators, a filtered noise burst, and gain envelopes — so
there are no audio files to ship or load.
<ParticleLogo :svg="mark" sound />
<ParticleLogo :svg="mark" :sound="{ volume: 0.5 }" />Cues are wired automatically:
| Interaction | Sound |
| ------------------ | -------- |
| Cursor enters | hover |
| Click / idle shoot | shoot |
| Bullet impact | hit |
| Morph → alt shape | sort |
| Morph → mark | close |
Browsers only allow audio after a user gesture, so the first click unlocks it
(hover sounds before any interaction are silently skipped). All logos share one
AudioContext.
Drive it directly if you like:
import { getSoundEngine } from '@tsogtoodev/particle-glock';
const audio = getSoundEngine();
audio.play('shoot'); // 'hover' | 'tap' | 'open' | 'close' | 'sort' |
// 'grab' | 'drop' | 'confirm' | 'copy' | 'error' |
// 'shoot' | 'hit' | ...
audio.setVolume(0.4);
audio.setEnabled(false);Recipe: back-to-top on scroll
Combine morphTo with an alt "arrow" SVG and drive it from scroll position:
const scrolled = () => window.scrollY > 24;
window.addEventListener('scroll', () => logo.value.morphTo(scrolled() ? 1 : 0));Notes
- Particles draw on a
position: absolutecanvas that overhangs the mark bypaddingpx; the wrapper reserves exactlywidth × heightfor layout. requestAnimationFramepauses while the tab is hidden (browser default), so the animation resumes when the tab is focused.- License: MIT.
