@sunsided/web-visual-fx
v1.1.0
Published
Modern visual effects for the web (liquid and frosted glass, SVG filters, holographic foil, squircles and more) as Svelte components and framework-agnostic custom elements.
Maintainers
Readme
Visual FX
Try it out: https://sunsided.github.io/web-visual-fx/
A playground for modern visual effects on the web, built as tokenized, fully configurable Svelte components. Two faces, one codebase:
- Playground site (SvelteKit, prerendered): one full-screen page per effect, with a control panel for every parameter and a generated code snippet.
- Component library (Storybook): every atom, molecule, organism and effect with
controls and docs, published under
/storybook/next to the site.
Effects so far:
| Effect | Techniques |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| Noise | feTurbulence, feColorMatrix, feComponentTransfer, mix-blend-mode |
| Frosted glass | backdrop-filter, feTurbulence → feDisplacementMap, masked gradient rim |
| Liquid glass | SDF-generated map via feImage → feDisplacementMap, feSpecularLighting |
| Chromatic aberration | feColorMatrix channel split, feOffset, feBlend, radial feDisplacementMap |
| Gooey | feGaussianBlur → feColorMatrix alpha threshold, feComposite |
| Lighting | feDiffuseLighting, feSpecularLighting, fePointLight, feTurbulence bump map |
| Duotone | feColorMatrix luminance, feComponentTransfer table, feComposite mix |
| Halftone | CSS dot gradients + contrast() threshold; feImage/feTile Bayer dither + discrete transfer |
| CRT | gradient scanlines and mask, feDisplacementMap barrel with a generated map, blur + screen bloom |
| Holographic foil | perspective tilt, pointer custom properties, mix-blend-mode, background-blend-mode |
| Spotlight cards | pointer-tracked radial-gradient, mask-composite: exclude, ResizeObserver |
| Aurora | @property-registered colors/angles, cos()/sin(), layered radial-gradient |
| Squircles | corner-shape / superellipse(), linear() spring easing, clip-path: path() fallback |
| Sticker outline | feMorphology dilate, feGaussianBlur + feColorMatrix threshold, feFlood, feComposite, feMerge |
| Edges and sketch | feConvolveMatrix (Sobel, Laplacian, emboss), feComponentTransfer abs/threshold, feTurbulence grain, feBlend |
| Posterize | feComponentTransfer type="discrete", feColorMatrix saturate, feConvolveMatrix ink lines, feMerge |
| Pixelate | feFlood + feComposite + feTile sample grid → feMorphology dilate, tiled feImage tile mask |
| Glitch | generated row-offset feImage → feDisplacementMap, channel split, CSS-clocked bursts |
| Magnifier | radial feImage zoom map → feDisplacementMap + feMerge, optional backdrop-filter: url() |
| Water ripple | animated feTurbulence on a feTiled tile slid with feOffset → feDisplacementMap; backdrop via backdrop-filter: url() |
| Caustics | feTurbulence type="turbulence" ridges → feComponentTransfer table, feDisplacementMap warp, feSpecularLighting |
| Ink bleed | feTurbulence + feDisplacementMap, feGaussianBlur + feColorMatrix soft threshold, feDiffuseLighting paper |
| Dissolve | feTurbulence threshold field, feComponentTransfer equalization, feImage direction ramp, Svelte transition |
| Kaleidoscope | wedge clip-path: polygon() from tan(), rotate: calc(i * 360deg / n), mirrored with scale: -1 1 |
| Neon | stacked text-shadow / drop-shadow(), stepped @property flicker (WCAG 2.3.1-checked) |
| Gradient text | background-clip: text (prefixed + unprefixed in @supports), -webkit-text-stroke, paint-order |
| Long shadow | generated N-step text-shadow list, color-mix() ramp |
| Border beam | @property <angle> → conic-gradient, mask-composite: exclude ring, blurred copy |
| Shimmer | translated sheen layer, background-clip: text, gradient mask-image, @property sweep |
| Split text | sibling-index() / sibling-count() delays, @starting-style, inline --i fallback |
| Scroll reveal | animation-timeline: view()/scroll(), animation-range, view-timeline, timeline-scope |
| View transitions | startViewTransition (document + element-scoped), view-transition-class, clip-path: circle() |
| Paint Worklet (experimental) | CSS Painting API paint(), CSS.registerProperty, gradient fallback |
| Water sim (experimental) | WebGL2 ping-pong float framebuffers, GLSL wave equation, refraction |
Backdrop displacement uses
backdrop-filter: url(#filter), which only renders in Chromium-based browsers. Firefox drops the whole backdrop filter (blur included) when the chain containsurl(); Safari is untested.GlassPaneltherefore leavesurl()out outside Chromium unlessforceDisplaceis set, so other browsers get blur + tint + grain + rim.
Using the package
The effects are published as @sunsided/web-visual-fx on npm (and GitHub Packages), in three flavors:
| Entry | What | For |
| ---------------------------------- | ------------------------------------------------------------ | ---------------------------------- |
| @sunsided/web-visual-fx/svelte | Svelte 5 components, shipped as source | Svelte and SvelteKit apps |
| @sunsided/web-visual-fx/elements | Custom elements (<vfx-glass> …), prebuilt | React 19, Vue, Angular, plain HTML |
| @sunsided/web-visual-fx | Schemas, defaults and pure helpers, one namespace per effect | Anything |
npm install @sunsided/web-visual-fxTagged releases are also published to GitHub Packages, which needs a token even for public packages: a
personal access token (classic) with read:packages, and in .npmrc:
@sunsided:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Svelte is an optional peer dependency: only the /svelte entry needs it. The elements bundle
their own copy of the Svelte runtime.
Svelte
<script>
import '@sunsided/web-visual-fx/tokens.css';
import { GlassPanel, HoloCard } from '@sunsided/web-visual-fx/svelte';
</script>
<GlassPanel blur={20} tintOpacity={0.4}>Frosted</GlassPanel>
<HoloCard label="My card">
{#snippet art()}<img src="/art.png" alt="" />{/snippet}
<p>Card text</p>
</HoloCard>tokens.css holds the design tokens and the two utilities the components rely on
(.visually-hidden, and pausing animations inside [data-motion="paused"]); it contains no
document reset.
Custom elements (React, plain HTML, …)
import '@sunsided/web-visual-fx/elements'; // registers all elements
// or only what you use: import '@sunsided/web-visual-fx/elements/glass';<vfx-glass blur-radius="20" tint-opacity="0.4">Frosted</vfx-glass>
<vfx-holo label="My card">
<img slot="art" src="/art.png" alt="" />
<p>Card text</p>
</vfx-holo>- Self-contained: each element carries its tokens in its shadow root; no global CSS
needed.
color-scheme(and so light/dark) is inherited from the page;data-themeon the element forces one. - Props: properties use the camelCase prop names (
el.tintOpacity = 0.4), attributes the kebab-case form (tint-opacity). Two props are renamed becauseHTMLElementalready has them:blur→blurRadiusandanimate→animated. - Content: children go into the default slot; HoloCard's art into
slot="art". - Events:
<vfx-gooey-menu>firesitemselect(the item) andopenchange,<vfx-squircle-group>firesselectedchange; the new value is inevent.detail, and theopen/selectedproperties stay in sync. - Motion: animated elements (
vfx-gooey,vfx-crt,vfx-aurora,vfx-glitch,vfx-ripple,vfx-caustics,vfx-kaleidoscope,vfx-neon,vfx-gradient-text,vfx-border-beam,vfx-shimmer,vfx-paint-pattern,vfx-water-sim) take apausedproperty, since a page-leveldata-motioncan't reach into a shadow root.@propertyrules are ignored inside a shadow root, so the elements register them for the document when they connect. - Not available as an element: spotlight cards (the grid measures its cards with
querySelectorAll, which doesn't see slotted children) andStagger(it indexes its direct children, which in a shadow root are a<slot>), parallax layers (they need the scene's named timeline, which doesn't cross into a shadow root) and the view-transition helpers (useviewTransition.runViewTransitionfrom the core entry); use the Svelte components.
React 19 passes props to custom elements as properties and on<event> props as listeners. For
JSX types, reference the declarations once:
import '@sunsided/web-visual-fx/elements';
import type {} from '@sunsided/web-visual-fx/elements/react';
<vfx-gooey-menu
items={[{ label: 'Share', icon: '↗' }]}
onitemselect={(e) => console.log(e.detail.label)}
/>;React 18 sets attributes instead of properties, so array props (items, selected) need a
ref.
Core
import { crt, glass, supportsCornerShape } from '@sunsided/web-visual-fx';
glass.glassDefaults.blur; // 14
crt.maxFlickerChange(0.03); // worst-case luminance change of the CRT flickerGetting started
Requires Node.js 24 (see .node-version) and Task.
corepack enable
task install
task dev # playground on http://localhost:5173
task storybook # component library on http://localhost:6006task lists everything; task ci runs the full pipeline locally (format, lint, types,
workflow linting with actionlint + zizmor, tests, build, package). task package builds the
npm package into dist/ and checks it with publint.
Structure
src/lib/
tokens/ primitives.css → semantic.css → utilities.css = tokens.css (shipped); base.css (site reset)
params/ parameter schemas: one definition drives defaults, controls, Storybook argTypes, code snippets
components/
atoms/ Slider, Switch, Select, ColorInput, Button
molecules/ Field, ParamControl, ControlGroup, CodeSnippet, StageHeader
organisms/ ParamControls, ControlPanel, Scene
templates/ PlaygroundLayout
fx/
noise/ noise.ts (schema), NoiseFilter (SVG <filter>), NoiseLayer
glass/ glass.ts (schema), DisplacementFilter (SVG <filter>), GlassPanel
…/ one folder per effect: schema + specs, filter/component(s), Preview, stories
fx/registry.ts every effect listed on the index
index.ts package entry: schemas and helpers
svelte.ts package entry: Svelte components
src/elements/
config.ts which components become custom elements, with extra props, slots, events
generate.ts renders the wrappers in generated/ and the .d.ts files (yarn elements:generate)
shadow.ts tokens and utilities adopted into every element's shadow root
src/routes/ index + one page per effectCustomizing
Every effect parameter is a prop with a sensible default (ranges and defaults live in the
effect's schema, e.g. fx/glass/glass.ts). Styling goes through three token layers:
- Primitives such as
--gray-*,--accent-*,--space-*. Re-skin everything with--accent-hue/--gray-hue. - Semantic roles such as
--color-surfaceand--color-accent, with light/dark vialight-dark(). Force a scheme withdata-theme="light" | "dark"on any element. - Component tokens that fall back to semantic ones, e.g.
<Slider --slider-fill="hotpink" />.
Adding an effect
- Create
src/lib/fx/<name>/<name>.tswith adefineSchema({...})and its defaults. - Build the effect from small pieces (an SVG filter atom, a surface component).
- Add
<Name>.stories.svelteusingtoArgTypes(schema). - Add
src/routes/<name>/+page.svelteusingParamState,ParamControlsandPlaygroundLayout. - Register it in
src/lib/fx/registry.ts. - Export the component from
src/lib/svelte.tsand the module fromsrc/lib/index.ts. - To ship it as a custom element, add it to
src/elements/config.tsand runtask elements:generate(a spec fails while the generated wrappers are out of date).
Deployment
.github/workflows/pages.yml runs on every push to main. It first runs the full CI
workflow (ci.yml, called as a reusable workflow) and only continues if that passes: it
builds the site with BASE_PATH taken from actions/configure-pages, builds Storybook into
build/storybook, and deploys to GitHub Pages. Pull requests run CI only.
In the repository settings, set Pages → Source to GitHub Actions.
Releases
Bump version in package.json, then push a matching semver tag:
git tag v1.1.0 && git push origin v1.1.0.github/workflows/release.yml runs CI, checks that the tag matches package.json, builds
the site and Storybook into web-visual-fx-<tag>.tar.gz (relative paths, so it works from any
directory), and creates the release with GitHub-generated notes and the archive attached. It
also builds the npm package and publishes it to GitHub Packages and, via trusted publishing
(OIDC, no token), to npmjs.com. Tags with a suffix such as
v1.2.0-rc.1 become prereleases and are published under the next dist-tag.
License
Licensed under any of the European Union Public Licence 1.2,
the MIT License, or the Apache License 2.0,
at your option (EUPL-1.2 OR MIT OR Apache-2.0).
