Component <fk-shader>
Seven styles from the Lab, packed into one small web component for your own pages. It renders an image or video as a live field of dots, squares, lines, dither, ASCII or LCD segments, or as soft glow. It reads your , and you can push it around with your finger or cursor.
11 KB gzipped · no dependencies · web component · TypeScript types
<fk-shader
src="your-video.webm"
variant="dots"
size="0.74"
interactive
alt="Describe your clip here">
</fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 4 / 5; }
</style>Installation
Two ways in. Pick one.
Option A: link it
One line, like loading a web font. This address is version 2.2.0 and never changes, so browsers keep it for a year and the integrity hash proves it is the file you expect.
- Add the script once, anywhere on the page.
<script src="https://shaders.fedekotek.design/v/2.2.0/fk-shader.min.js" integrity="sha384-v9OVsPd28wQWZ35YMxTUkP6H31rE7VTpesVZ+rXCmYRXj5PnzNCa8dsFiMU+MAtT" crossorigin="anonymous" defer></script>Want every fix as it ships instead? This address always serves the latest build, including major versions that may change the API. Pin for production.
<script src="https://shaders.fedekotek.design/fk-shader.min.js" defer></script> - Give it a size in your CSS. It fills whatever box you put it in.
<style> fk-shader { display: block; width: 100%; aspect-ratio: 3 / 4; } </style> - Drop the tag where you want the picture, as in Usage.
Option B: copy it
Own the code, the way shadcn works. Nothing to install, nothing to keep in sync.
- Download
fk-shader.js(readable) orfk-shader.min.js(about 11 KB gzipped) into your project. Using TypeScript? Takefk-shader.d.tstoo. - Load it, with the path relative to your page.
<script src="js/fk-shader.js" defer></script> - Give it a size and drop the tag, same as in option A.
Open your page from a server, not by double-clicking the file. Opened as file://, the browser will not let the component read pixels and you get a blank box. Any local server works, for example python3 -m http.server.
Coming from <dv-media> 1.x? See Coming from dv-media 1.x.
Usage
<fk-shader
src="your-video.webm"
variant="dots"
interactive
alt="The team walking through the office">
</fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 3 / 4; }
</style>It needs a size: without a height it collapses to 1 px, shows nothing, and says so in the console. Pass a video or an image file, or #id to draw a video or image already on the page (it reads it, and leaves playing and pausing to you). Videos are recognized by their file extension (mp4, webm, mov, m4v, ogv). For any other URL (blob:, streaming, an API), put a <video id="x"> on the page and pass src="#x". Instances with the same source share one decoder.
Knowing when it is on screen, or why not
// run this after the tag, or from a script with defer
const shader = document.querySelector('fk-shader');
shader.addEventListener('ready', () => { /* first frame drawn */ });
shader.addEventListener('error', (e) => console.log(e.detail.reason)); // 'load' or 'cors'
if (shader.error) console.log(shader.error); // it failed before you were listeningIn React, Vue or Svelte
It is a plain custom element, so it works in any framework once the script is loaded. With a bundler, import './fk-shader.js' once in your entry; it does nothing on the server, so SSR is safe. Every attribute is also a property, and booleans treat "false" as off, so interactive={false} does what it says.
In React, listen for ready and error through a ref: an onReady or onError prop does not fire in React 18 or 19. In React 18, use class, not className.
// React 18 and 19
import { useEffect, useRef } from 'react';
import './fk-shader.js';
export function Shader({ clipUrl, isTouch }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
const on = () => { /* first frame drawn */ };
el.addEventListener('ready', on);
return () => el.removeEventListener('ready', on);
}, []);
return (
<fk-shader ref={ref} src={clipUrl} variant="lines" interactive={isTouch} alt=""
style={{ display: 'block', aspectRatio: '3 / 4' }} />
);
}In Vue, tell the compiler it is a custom element: set compilerOptions.isCustomElement to (tag) => tag === 'fk-shader'. With TypeScript, fk-shader.d.ts types the element and adds the tag to JSX, for React 18 and 19. Download it into your src/, or add its path to include in tsconfig.json.
Examples
Variants
Seven ways to draw the same picture. Each has its own default pitch, shown under it (default sizes are in the API).
Glow paints with light instead of marks: the bright parts glow over the dark, with a halo and a little film grain. Drag across it to part the light.
<fk-shader src="your-video.webm" variant="dots" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="squares" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="lines" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="dither" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="ascii" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="lcd" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="glow" interactive alt=""></fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 3 / 4; }
</style>Theming
Color comes from two CSS custom properties, --fk-shader-bg and --fk-shader-ink, in any CSS color format: hex, rgb, hsl, oklch, a name, even currentColor. Set them on a class, or once at the root so they follow your dark mode. On dark paper the light parts of the picture get the ink. The LCD variant has its own pair, --fk-shader-lcd-bg and --fk-shader-lcd-ink, for the glass and the segments, and so does glow: --fk-shader-glow-bg for the dark behind it and --fk-shader-glow-ink for the color of the light. From that one color it builds the whole ramp, deeper and redder in the shadows, paler and hotter in the highlights.
<fk-shader class="t-riso" src="your-video.webm"
variant="dots" alt=""></fk-shader>
<fk-shader class="t-print" src="your-video.webm"
variant="squares" alt=""></fk-shader>
<fk-shader class="t-term" src="your-video.webm"
variant="ascii" pitch="8" alt=""></fk-shader>
<style>
.t-riso {
--fk-shader-bg: #F3EFE3;
--fk-shader-ink: #FF4D2E;
}
.t-print {
--fk-shader-bg: #0B2A5B;
--fk-shader-ink: #DCE8FF;
}
.t-term {
--fk-shader-bg: #060A06;
--fk-shader-ink: #5BF08A;
}
fk-shader { display: block; aspect-ratio: 3 / 4; }
/* or once for the whole site, following dark mode */
:root {
--fk-shader-bg: hsl(50 17% 91%);
--fk-shader-ink: hsl(52 21% 20%);
}
@media (prefers-color-scheme: dark) {
:root {
--fk-shader-bg: hsl(240 7% 4%);
--fk-shader-ink: hsl(50 12% 83%);
--fk-shader-lcd-bg: #121409; /* LCD glass and segments */
--fk-shader-lcd-ink: #98A07A;
}
}
</style>Image source
Stills work too. Once the picture settles it stops drawing entirely, until you touch it (lcd digits wake briefly to turn over).
<fk-shader src="still.jpg" variant="dither" pitch="4"
interactive alt="Spheres, dithered"></fk-shader>
<fk-shader src="still.jpg" variant="lcd" lcd="pixels" pitch="12"
interactive alt="Spheres on an LCD"></fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 3 / 4; }
</style>Interactive
Add interactive and a finger or cursor pushes the cells aside, then a spring brings them home. On an LCD, pressing bruises the glass dark, and on glow the light parts around your finger. On a phone, drag sideways: a vertical drag still scrolls the page.
<fk-shader src="your-video.webm" variant="dots" pitch="12" interactive alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="lcd" lcd="pixels" pitch="12" interactive alt=""></fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 3 / 4; }
</style>Proximity
For glow, map="proximity" lights up the nearest person instead of what is bright, like a depth camera: dark hair glows like the face, a bright wall stays dark. It loads a small person-finding model the first time an element asks for it, about 10 MB once per page. People only: with nobody in the picture, glow reads brightness.
<fk-shader src="your-video.webm" variant="glow" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="glow" map="proximity" alt=""></fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 3 / 4; }
</style>Tuning
The same clip, first as it comes, then with one attribute changed each time.
<fk-shader src="your-video.webm" variant="dots" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="dots" pitch="6" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="dots" pitch="16" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="dots" size="0.6" alt=""></fk-shader>
<fk-shader src="your-video.webm" variant="dots" invert alt=""></fk-shader>
<!-- a portrait clip in a square box: contain shows all of it, with bars at the sides -->
<fk-shader class="square" src="your-video.webm" variant="dots" fit="contain" alt=""></fk-shader>
<style>
fk-shader { display: block; aspect-ratio: 3 / 4; }
fk-shader.square { aspect-ratio: 1; }
</style>API reference
Attributes
Every attribute is also a property (el.variant = 'lcd'). Booleans are on when present, and "false" counts as off.
| Attribute | Type | Default | Description |
|---|---|---|---|
src | string | none | Video or image URL, or #id of a video or image already on the page. A borrowed video is read, never played or paused. Videos are recognized by their file extension (mp4, webm, mov, m4v, ogv). For any other URL (blob:, streaming, an API), put a <video id="x"> on the page and pass src="#x". |
variant | dots squares lines dither ascii lcd glow | dots | How each cell is drawn. |
pitch | number, px | dots 10, squares 14, lines 11, dither 5, ascii 12, lcd 16, glow 5 | Size of the grid cell. For glow, a bigger pitch is a softer picture. Minimum 3. Past a cap the grid opens up by itself, so a big box never freezes the page: 9,000 cells for lines, 12,000 for lcd, 16,000 for ascii, 30,000 for the others. |
size | number, 0 to 2 | dots 1, squares 0.95, lines 1.35, glow 1 | Largest mark relative to the cell, for dots, squares and lines. 1 is as wide as the cell. For glow, how strong the halo around the light is: 0 turns it off. |
contrast | number | 1 | Multiplies the variant's tone curve. Above 1 thins the mid-tones, below 1 fills them. Useful range 0.5 to 2. |
exposure | number | 0 | Adds or removes ink across the whole picture: positive is darker on light paper and brighter on dark paper. Useful range -0.5 to 0.5. |
fit | cover contain | cover | Crop to fill, or show the whole frame. |
invert | boolean | false | Swap which end of the tone scale gets the ink. |
interactive | boolean | false | Cells react to touch and cursor. Off under reduced motion and while paused, and then swipes are not captured either. Only an interactive instance holds sideways drags; the rest leave swipes and pinch-zoom to the page. |
lcd | digits pixels | digits | For the LCD variant: seven-segment numbers or plain pixels. |
map | brightness proximity | brightness | For glow: what lights up. proximity lights up the nearest person with a person-finding model that loads the first time an element asks for it (about 10 MB, from cdn.jsdelivr.net and storage.googleapis.com). With nobody in the picture, or if it cannot load, glow reads brightness. See Proximity. |
paused | boolean | false | Freezes the picture, and it stops reacting to touch. |
alt | string | "" | Accessible description. Leave empty when decorative, and assistive tech skips it. |
CSS custom properties
Any CSS color format works, including currentColor and light-dark(). A value that is not a color logs a warning and falls back to the default.
| Property | Default | Description |
|---|---|---|
--fk-shader-bg | transparent | Paper. Its lightness decides which end of the picture gets ink. Left transparent, the ink decides instead: light ink is read as a dark page. |
--fk-shader-ink | the element's color | The marks. |
--fk-shader-lcd-bg, --fk-shader-lcd-ink | #A6AA80 #1F241B | Glass and segments for the LCD variant. |
--fk-shader-glow-bg, --fk-shader-glow-ink | #0B0908 #F27A1E | The dark behind the glow variant, and the color of its light. Shadows run deeper and highlights paler from that one color. With a light background the dark parts of the picture glow instead. |
Methods, events and parts
| Name | Description |
|---|---|
play(), pause() | Same as removing or setting paused. |
ready event, el.ready | Fires when a source draws its first frame, once the element is on screen, and again after each src change. Bubbles. el.ready stays true until the source changes, so a listener added late can check it. |
error event, el.error | The source could not be loaded (detail.reason is load), or it is on another origin and failed or cannot be read (cors). Also logged once as a warning that says what to do. Fires a tick later so a listener added right after the tag hears it, and does not bubble. el.error holds the reason, or null. |
::part(canvas) | Style the internal canvas from outside. |
window.FkShader | The element class, for instanceof checks. |
Accessibility and performance
With an alt the canvas is an image with that label. With an empty alt it is hidden from assistive tech as decoration. With reduced motion on, the picture is drawn once without easing, a video it loaded stays paused, and interactive is off. The page's Pause button above does the same for everyone.
It only draws when something changes: a new video frame, cells settling, a finger on it. Offscreen instances stop, and a still image stops drawing once it settles (lcd digits wake briefly to turn over). Cost grows with the number of cells, and halving the pitch makes four times as many, so a small pitch on a big box is the expensive combination.
Media from another domain needs CORS headers, or the browser will not let the component read its pixels (you get an error event with cors, and a console warning that names the missing header). It works under a strict Content Security Policy: its styles use a constructed stylesheet, not an inline style tag. The one exception is map="proximity", whose model needs cdn.jsdelivr.net and storage.googleapis.com allowed, and WebAssembly ('wasm-unsafe-eval'); without them glow reads brightness.
Coming from dv-media 1.x
This component used to be called <dv-media>. Nothing breaks if you leave it: the old tag and its pinned links keep working as they are. The rolling /dv-media.min.js is frozen at 1.2.0 and gets no more fixes. To move to 2.2.0, do it in this order:
- Replace the whole script line with the new one from Installation. Do this first, and not with a find-and-replace of "dv-media": that would also rewrite the old address, and point it at a file that does not exist. If you copied the file, download
fk-shader.jsin its place. - Rename the tag,
<dv-media>to<fk-shader>, in your HTML and in any CSS or script that selects it. - Rename the color variables,
--dv-bgto--fk-shader-bg, and the same for--dv-ink,--dv-lcd-bgand--dv-lcd-ink. Attributes, events and variants stay the same. window.DvMediais nowwindow.FkShader. With TypeScript, swapdv-media.d.tsforfk-shader.d.tsand renameDvMediaElement,DvMediaVariant,DvMediaErrorReasonandDvMediaAttributestoFkShaderElement,FkShaderVariant,FkShaderErrorReasonandFkShaderAttributes.
Lab
The playground has 31 styles, layers and brushes. Tune a look there, then copy it as a tag with Copy as <fk-shader>, in More, Component. Looks that use a style outside these seven copy as the closest variant. Layers (Marks, Captions, Grain and the rest) and the Lab's color modes stay in the Lab.