npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vctrfx

v0.10.1

Published

Post-processing effects for any SVG. Scanlines, bloom, glitch, halftone and more — in, out, still an SVG.

Readme

vctrfx

vctrfx(logo, [crt()])

Post-processing effects for any SVG.

Give it an SVG, get back an SVG — now with scanlines, bloom, glitch, halftone or a dozen other looks baked in. Still vector, still editable, no rasterizing, no DOM.

npm license


Highlights

  • ✅ Vector in, vector out — no canvas, no rasterizing, no <image> payloads. The result still scales and still has your paths in it.
  • ✅ Runs anywhere — zero runtime dependencies, no DOM. Node, Bun, Deno, edge functions, build scripts, browsers.
  • ✅ 27 effects + 8 presets — every one tuned to look right with no arguments.
  • ✅ Follows your artwork's shape — overlays clip flush to a rounded frame or any silhouette, never over it.
  • ✅ Deterministic — same input and settings give byte-identical output, so it drops into a build pipeline and a snapshot test.
  • ✅ 14 KB gzipped, fully tree-shakeable. Import one effect, ship one effect.
  • ✅ Self-contained motion — opt into animate and the output loops on its own inside an <img>, no JavaScript attached.
  • ✅ Keeps existing animation — an SVG that already animates keeps animating.

Table of contents


Installation

npm install vctrfx

Quick start

import { readFileSync, writeFileSync } from 'node:fs'
import { vctrfx, crt } from 'vctrfx'

const source = readFileSync('logo.svg', 'utf8')

writeFileSync('logo-crt.svg', vctrfx(source, [crt()]))

Effects are applied in order, exactly as you would stack them in an editor:

import { vctrfx, duotone, halftone, grain } from 'vctrfx'

const poster = vctrfx(source, [
  duotone({ shadow: '#111d4a', highlight: '#ff6b6b' }),
  halftone({ size: 5, angle: 15 }),
  grain({ amount: 0.4 }),
])

API

vctrfx(source, effects?, settings?)

Takes an SVG string, returns an SVG string.

const output = vctrfx(source, [bloom()], { seed: 'hero', format: 'pretty' })

createPipeline(effects, settings?)

A reusable, named stack of effects. Build it once, apply it to many files.

import { createPipeline, duotone, grain } from 'vctrfx'

const brand = createPipeline([duotone({ shadow: '#001427' }), grain()], { seed: 'brand' })

icons.map(brand.apply)

compose(name, effects)

Collapses several effects into one, so a house style becomes a single effect that can be passed around, exported, and stacked with others.

export const houseStyle = compose('house-style', [contrast({ amount: 1.1 }), grain(), vignette()])

resolveEffects(input) and applyEffects(source, input, settings?)

The same stack, written as data rather than imports — for config files, CLI flags and anything else that has to travel as a string. See Naming effects instead of importing them.

import { applyEffects } from 'vctrfx'

applyEffects(source, 'scanlines(gap:3,opacity:.35) grain(amount:.4)')

toDataUri(svg, options?)

element.style.backgroundImage = `url("${toDataUri(vctrfx(source, [crt()]))}")`

URI-encoded by default (smaller and readable); pass { base64: true } if you need it.

Settings

| Setting | Default | What it does | | --------- | ------------ | ------------ | | seed | 'vctrfx' | Seeds every random decision. Change it to reroll a glitch or a grain field; keep it to get the same output forever. | | prefix | 'vctrfx' | Prefix for every generated id and class. | | scope | auto | Id namespace. Derived from the source, seed and effects so several vctrfx outputs can be inlined in one page without colliding. Set it yourself for stable, readable ids. | | clip | 'shape' | Trim the finished result to the artwork's own frame, so the output silhouette matches the input exactly. 'none' lets effects spill past it. | | animate | true | Global switch for motion. false strips animation from every effect that asked for it — useful for print, PDF or snapshot tests. | | format | 'preserve' | 'preserve' keeps the input's whitespace, 'pretty' re-indents, 'minify' drops comments and layout whitespace. |

Effects

Light

| Effect | Options | | ------ | ------- | | blur | radius 3, axis 'both' \| 'horizontal' \| 'vertical' | | bloom | radius 8, threshold 0.55, intensity 1.1 | | glow | color '#ffffff', radius 6, spread 0, intensity 1 | | shadow | x 3, y 4, blur 4, color '#000000', opacity 0.4 |

Colour

| Effect | Options | | ------ | ------- | | grayscale | amount 1 | | saturate | amount 1.4 | | hueRotate | angle 90 | | invert | amount 1 | | brightness | amount 1.15 | | contrast | amount 1.25 | | sepia | amount 1 | | fade | amount 0.7 | | posterize | steps 5, includeAlpha false | | threshold | level 0.5, dark '#000000', light '#ffffff' | | duotone | shadow '#12263a', highlight '#f4d35e', mix 1 | | tint | color '#ff2d55', amount 0.45 |

Texture and distortion

| Effect | Options | | ------ | ------- | | grain | amount 0.32, size 0.8, monochrome true, blend 'overlay', animate false, speed 12 | | scanlines | gap 4, thickness 1.5, opacity 0.28, color '#000000', angle 0, blend 'multiply', animate false, speed 6, clip 'shape' | | chromaticAberration | offset 2, angle 0 | | glitch | intensity 0.5, slices 7, colorShift true, animate false, speed 1 | | pixelate | size 8 | | halftone | size 6, angle 45, levels 4, color '#111111', background '#ffffff', keepSource false, clip 'shape' | | vignette | amount 0.65, radius 0.6, softness 0.7, color '#000000', clip 'shape' | | outline | width 2, color '#000000', position 'outside' \| 'inside' | | wave | amplitude 12, frequency 0.02, octaves 2, animate false, speed 0.15 | | emboss | depth 1, angle 135, desaturate true | | sharpen | amount 1 |

Every option is optional. bloom() on its own is tuned to look right.

Presets

Finished looks, each one a compose of the effects above.

import { crt, vhs, riso, xerox, neon, film, newsprint, cyberpunk } from 'vctrfx'

vctrfx(source, [crt({ animate: true })])
vctrfx(source, [riso({ shadow: '#2b3a67', highlight: '#ff5a5f' })])
vctrfx(source, [neon({ color: '#4cc9f0' })])

| Preset | Look | | ------ | ---- | | crt | Phosphor glow, scanlines, colour fringing, vignette | | vhs | Tape wobble, heavy fringing, rolling lines, noise | | riso | Two-colour risograph with paper grain | | xerox | Blown-out photocopy | | neon | Saturated sign glow | | film | Halation, grain and a soft vignette | | newsprint | Halftone dots on off-white stock | | cyberpunk | Sliced, shifted, bloomed, scanned |

Tuning a preset

A preset is a stack of effects, and every one of them is reachable by name. Override the effect you want to move and the rest of the recipe stays put:

vctrfx(source, [film({ grain: { amount: 0.5 } })])

Overrides merge over the preset's values rather than replacing the effect, so changing one knob keeps the others. film runs bloom at radius: 6, threshold: 0.68 — this moves the radius and keeps the threshold:

vctrfx(source, [film({ bloom: { radius: 14 } })])

Each preset's options type lists exactly the effects it contains, so editors autocomplete them and film({ scanlines: ... }) is a type error — film has no scanlines in it.

crt({
  animate: true,
  scanlines: { gap: 6, blend: 'overlay' },
  vignette: { amount: 0.9 },
})

The shorthands stay: animate on crt, vhs and cyberpunk, shadow/highlight on riso, color on neon. A per-effect override wins when both are given.

Every preset's own values are exported, so you can read what you are overriding, build a variant from them, or drive a UI off them:

import { film, FILM_DEFAULTS } from 'vctrfx'

FILM_DEFAULTS.bloom              // { radius: 6, threshold: 0.68 }
film({ bloom: { ...FILM_DEFAULTS.bloom, radius: 20 } })

CRT_DEFAULTS, VHS_DEFAULTS, RISO_DEFAULTS, XEROX_DEFAULTS, NEON_DEFAULTS, FILM_DEFAULTS, NEWSPRINT_DEFAULTS and CYBERPUNK_DEFAULTS are all exported.

Examples

Every effect and preset ships as a runnable TypeScript script under examples/ — the linked name opens the code, which shows the exact options used. Each script writes its results into examples/svgs, and runs as-is with no build step.

npm run examples                    # render all 39
node examples/effects/halftone.ts   # or just one
npm run gallery                     # side-by-side overview page

Every example runs twice: on still artwork, and on a spinning 3D solid — so you can see each effect hold up on something that moves. The gallery page plays both.

Effect examples

| Example | Before | After | | ------- | ------ | ----- | | blur | | | | bloom | | | | glow | | | | shadow | | | | grayscale | | | | saturate | | | | hue-rotate | | | | invert | | | | brightness | | | | contrast | | | | sepia | | | | fade | | | | posterize | | | | threshold | | | | duotone | | | | tint | | | | grain | | | | scanlines | | | | chromatic-aberration | | | | glitch | | | | pixelate | | | | halftone | | | | vignette | | | | outline | | | | wave | | | | emboss | | | | sharpen | | |

Preset examples

| Example | Before | After | | ------- | ------ | ----- | | crt | | | | cyberpunk | | | | film-3d | | | | film | | | | neon | | | | newsprint | | | | riso | | | | vhs | | | | xerox | | |

Animated examples

Applied to examples/sources/motion.svg, which loops on its own in plain SMIL. Both columns are playing: the source keeps its motion, and the second adds vctrfx's on top.

| Example | Before | After | | ------- | ------ | ----- | | motion-crt | | | | motion-layered | | |

The output keeps your artwork's shape

An SVG with rounded corners should come back with the same rounded corners. Two things would otherwise break that, and vctrfx handles both.

Overlays. scanlines, vignette and halftone lay something over the drawing, and a naive overlay is a rectangle that paints straight over your corners.

Filters. bloom, glow, chromaticAberration and friends bleed outward by design. On a rounded card that bleed lands outside the corner arc as a soft halo and colour fringing, so the silhouette stops being yours.

So vctrfx works out the shape your artwork actually has, and holds everything to it:

  1. A frame it can measure. If your artwork sits on a full-bleed backdrop — a <rect> filling the viewBox, rounded or not, however deeply nested in groups — that rect's geometry becomes a clipPath, corner radius included. Overlays clip to it, and so does the finished result, so filter bleed is trimmed at the same arc.
  2. A clip you already declared. A clip-path on the root <svg> is reused as-is.
  3. Anything else. For artwork with no frame, overlays fall back to a mask built from the drawing's own alpha, and nothing is clipped globally — a glow on a logo still glows outward, which is the point of it.

Measured on a rounded terminal card with the crt preset: without this, 1853 pixels of the silhouette differ from the input. With it, 2 — both antialiasing on the arc.

vctrfx(roundedCard, [crt()])

Two escape hatches, at different levels:

vctrfx(source, [scanlines({ clip: 'viewport' })])   // this overlay fills the frame
vctrfx(source, [glow()], { clip: 'none' })          // let everything spill past the frame

The measured-frame path is free — one clipPath, shared by every effect that needs it. The alpha fallback costs one extra render of the artwork per overlay effect.

Motion

Effects with an animate option emit their own <animate> elements and CSS keyframes into the output. Nothing is attached at runtime, so the file animates on its own — as an <img src>, a CSS background-image, or inlined in a page.

const banner = vctrfx(source, [scanlines({ animate: true, speed: 4 }), grain({ animate: true })])

Turn all of it off in one place when you need a still frame:

vctrfx(source, [crt({ animate: true })], { animate: false })

Animated SVGs go in, animated SVGs come out

An input that already animates keeps animating. <animate>, <animateTransform>, <set> and CSS keyframes in the source are carried through untouched — the artwork is wrapped, never rewritten — and the effect is applied to every frame as it plays.

const styled = vctrfx(spinner, [crt()])

Your motion and vctrfx's motion compose, in one self-contained file:

const banner = vctrfx(spinner, [
  bloom({ radius: 6, threshold: 0.45 }),
  glitch({ intensity: 0.6, animate: true }),
  scanlines({ gap: 3, animate: true }),
  grain({ amount: 0.35, animate: true }),
])

Both are in the animated examples above, rendered from a looping source.

Naming effects instead of importing them

Every effect and preset is reachable by name, so a stack can be written as a plain string or a JSON-safe object and still produce exactly what the imports would.

import { applyEffects, resolveEffects, vctrfx } from 'vctrfx'

applyEffects(source, 'crt')
applyEffects(source, 'scanlines(gap:3,opacity:.35) grain(amount:.4)')
applyEffects(source, [{ scanlines: { gap: 3 } }, 'grain', crt()])
applyEffects(source, { use: 'crt', seed: 'hero', animate: false })

resolveEffects returns { effects, settings } if you would rather hand the result to vctrfx() or createPipeline() yourself.

The syntax

| Written | Means | | ------- | ----- | | crt | The effect or preset by that name, with its own defaults | | scanlines(gap: 3) | Arguments as key: value, comma separated. = also works | | grain(monochrome) | A bare key is true | | halftone(background: none) | none and null are null; #1c1c1c stays a colour | | crt(scanlines.gap: 3) | A dotted key reaches inside a preset, leaving the rest of the recipe alone | | scanlines grain | A stack, applied left to right. ,, + and \| separate too |

Names are matched loosely — chromatic-aberration, chromaticAberration and chromatic all land on the same effect — and a typo is an error with a suggestion rather than a silently missing effect:

VctrfxError: Unknown effect "scanline". Did you mean "scanlines"?

EFFECT_NAMES and PRESET_NAMES list what is available, which is what the CLIs built on this print for --list-effects.

Writing your own effect

An effect is a name plus a list of stages, and there are two kinds of stage. A filter stage contributes SVG filter primitives; consecutive filter stages are merged into a single <filter> element automatically. A layer stage restructures the artwork — wrapping it, duplicating it, or laying something over it.

import { defineEffect, filterStage, series } from 'vctrfx'

export const solarize = ({ level = 0.5 } = {}) =>
  defineEffect('solarize', [
    filterStage(
      (io) => ({
        primitives: series(io, [
          { name: 'feComponentTransfer', children: [/* ... */] },
          { name: 'feColorMatrix', attributes: { type: 'saturate', values: 1.4 } },
        ]),
      }),
      { margin: 10 },
    ),
  ])

series wires in and result down the chain for you. Reach for io.input and io.output directly when an effect needs to branch — bloom and chromaticAberration are worked examples.

A layer stage receives the current artwork and returns the new artwork, plus anything it needs in <defs> or in a <style> block:

import { cover, defineEffect, group, layerStage } from 'vctrfx'

export const wash = ({ color = '#ff2d55' } = {}) =>
  defineEffect('wash', [
    layerStage((content, context) => ({
      content: group([content, cover(context.viewport, { fill: color, opacity: 0.2 })]),
    })),
  ])

The context handed to every stage carries the resolved viewport, a seeded random(key) and range(min, max, key), and uid(hint) for collision-free ids. Randomness is keyed rather than sequential, which is what keeps output byte-stable.

Recipes

React

import { useMemo } from 'react'
import { vctrfx, crt } from 'vctrfx'

const Poster = ({ svg }: { svg: string }) => {
  const html = useMemo(() => vctrfx(svg, [crt()]), [svg])
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}

Build step

import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createPipeline, duotone, grain } from 'vctrfx'

const brand = createPipeline([duotone(), grain()], { format: 'minify' })

readdirSync('icons').forEach((file) => {
  writeFileSync(`dist/${file}`, brand.apply(readFileSync(`icons/${file}`, 'utf8')))
})

In the browser

const styled = vctrfx(await fetch('/logo.svg').then((response) => response.text()), [neon()])
document.querySelector('#logo')!.innerHTML = styled

Notes

  • Browser support. Everything is standard SVG 1.1 filters plus mix-blend-mode, which covers every current browser. pixelate leans on feTile and renders slightly differently in Safari.
  • Resolution. Browsers rasterize filter regions at the SVG's own coordinate scale. If a filtered SVG is displayed much larger than its viewBox, the filtered parts can soften. Author at the size you intend to display, or scale the viewBox up.
  • Ids. Generated ids are namespaced per document, so several vctrfx outputs can live in one page. Pass scope if you want to name that namespace yourself.
  • Structure. Non-rendering nodes — <title>, <desc>, <defs>, <style>, <metadata> — are left where they are. Only the drawn content is wrapped.

Development

npm test           # 126 tests, no test framework needed
npm run typecheck
npm run build      # ESM + CJS + types
npm run examples   # renders examples/svgs
npm run gallery    # renders examples/gallery.html

The repo is TypeScript end to end — source, tests, examples and build scripts. Nothing is written in JavaScript, and Node runs all of it directly.

License

MIT © Tal Hayut