Everything Refrakt draws — images, video frames, provider posters — becomes a WebGL texture sampled through the optics shader. This page covers how to prepare each media kind, the exact markup for each, and how the engine manages the GPU memory behind it all.
Image sizing
Slides are cover-fit: the texture is scaled to fill the container and
cropped on the overflowing axis, exactly like CSS object-fit: cover. Author
images accordingly:
- 1600 px on the longest edge is the sweet spot for full-bleed heroes. It stays sharp at 2× device pixel ratio on typical laptop widths while keeping decode times and GPU memory low.
- Textures are capped at 2048 px internally regardless of the source file or DPR — pixels beyond that are paid for in bytes and decode time but never displayed. Don’t ship 4000 px files.
- Keep one aspect ratio per slider. Cover-fit hides mismatches by cropping, but consistent framing keeps captions and focal points predictable.
- JPEG at quality 75–82 is ideal. The optics pass adds grain and light on top, which visually masks compression far better than a static viewer would.
The full-resolution file goes in data-rf-src; the inline
src is the tiny placeholder (next two sections):
<figure class="rf-slide">
<img src="img/peak-lqip.jpg" data-rf-src="img/peak.jpg" alt="North face at dawn">
<figcaption class="rf-text"><h2>North Face</h2></figcaption>
</figure>
If an <img> has only an inline src and no
data-rf-src, that file is treated as both placeholder and final image —
fine for thumbnails-only sliders, but full-bleed heroes should always split the two.
Responsive sources — data-rf-srcset
To serve different resolutions per device, use data-rf-srcset with the
same syntax as native srcset — width descriptors only
(800w, 1600w…). Density descriptors (2x) are not
supported and are ignored.
<img src="img/peak-lqip.jpg"
data-rf-srcset="img/peak-800.jpg 800w,
img/peak-1600.jpg 1600w,
img/peak-2048.jpg 2048w"
alt="North face at dawn">
Selection happens once, when the slide enters the preload window:
- Target width = container CSS width ×
min(devicePixelRatio, quality.maxDPR)(default DPR cap: 2). - The smallest candidate ≥ target wins; if none is large enough, the largest available is used.
- If
data-rf-srcsetis absent,data-rf-srcis used; if that is also absent, the inlinesrcis final.
Container resizes after load do not re-fetch a larger candidate — selection is deliberately one-shot to avoid download storms during window drag-resizes.
The LQIP frost-up pattern
Loading is part of the aesthetic, not something to hide. The inline
<img src> is the low-quality placeholder by contract, and it
arrives on screen behind procedural frost:
- The inline LQIP uploads immediately and renders frosted — the frost surface and its blur are forced on while loading, whatever glass kind the slider is configured with. A frosted LQIP on screen counts as “ready”; the slider never blocks on full-resolution files.
- The full-resolution file decodes off the critical path and swaps in between frames — never mid-frame, so there is no visible pop.
- On swap, the frost veil melts away over 600 ms
(
cubic-bezier(0.33, 0, 0.2, 1)), revealing the sharp image — condensation clearing from glass. Under reduced motion this becomes a 200 ms linear fade.
Markup — LQIP inline, full res deferred:
<figure class="rf-slide">
<!-- src: the LQIP (≤ 64 px long edge, same aspect) · data-rf-src: full res -->
<img src="img/peak-lqip.jpg" data-rf-src="img/peak.jpg" alt="North face at dawn">
<figcaption class="rf-text"><h2>North Face</h2></figcaption>
</figure>
LQIP guidance: 64 px or less on the long edge, same aspect ratio as the full file, quality ~50. At that size each placeholder is 1–2 KB and the initial paint is effectively free.
To disable the choreography set "media": { "frostUp": false } — the LQIP
then renders as-is (blurred only by its own low resolution) and the full image swaps in
hard.
Lazy loading is on by default: only the current slide ±
media.preload neighbors (default 1) are fetched, and the window advances
mid-drag so the incoming slide is already decoding before release. Set
"media": { "lazy": false } to eager-load everything.
Self-hosted video
Self-hosted video renders inside the optics — frames are uploaded to a texture every frame and refract, disperse, and relight like any image. Requirements:
- Format: MP4 with H.264 video. This is the one codec every
supported browser decodes. WebM/VP9 works where supported, but always provide the
H.264 MP4. HLS (
.m3u8) plays only where natively supported (Safari/iOS); on other browsers the slide falls back to its poster image. - Muted autoplay. Playback always starts muted —
mutedis forced whenever autoplay is in effect, because unmuted autoplay is blocked by every modern browser. Refrakt never attempts it. If a browser still blocks the firstplay(), the poster frame stays up and playback retries on the next user gesture. playsinlineis set automatically (on by default inmedia.video) so iOS renders frames inline instead of hijacking into the native fullscreen player.- Always supply
data-rf-poster— it is the LQIP-style first paint, the fallback if playback fails, and the still used when the video is evicted from the preload window. - Keep clips short and loopable (5–15 s), 1280–1920 px wide, and strip the audio track — it will never be heard in the slider and only costs bandwidth.
Markup — the video URL and poster live on the figure:
<figure class="rf-slide"
data-rf-video="video/reel-01.mp4"
data-rf-poster="img/reel-01-poster.jpg">
<img src="img/reel-01-lqip.jpg" alt="Reel 01 — title sequence">
<figcaption class="rf-text"><h2>Title Sequence</h2></figcaption>
</figure>
Behavior to know about (all automatic):
- A video plays only while its slide is the centered slide and the tab is visible; leaving center pauses (resume, not restart). Off-window videos cost zero per frame.
- A shared pool of at most 4
<video>decoders serves all instances on the page (mobile Safari caps concurrent decoders). With more simultaneous video slides than that, the furthest-from-center slides show their poster stills. - Defaults live under
media.video:{ "muted": true, "autoplay": true, "loop": true, "playsinline": true }. - With
lightbox.enabled, opening a video slide migrates the real<video>element into the lightbox with native controls — and since a user gesture opened it, sound is allowed there.
The YouTube / Vimeo poster pattern
Honest limitation first: a cross-origin iframe can never be a WebGL
texture — no library can refract live YouTube or Vimeo playback, and Refrakt
does not pretend to. Instead, provider slides use the poster pattern: a still
poster image renders inside the optics (it refracts, tears, and frosts like any slide,
and gets a play badge), and clicking it opens playback in a lightbox iframe. Provider
slides force the lightbox on for themselves even when lightbox.enabled is
false — playback has nowhere else to go.
<!-- YouTube — bare video ID -->
<figure class="rf-slide"
data-rf-youtube="dQw4w9WgXcQ"
data-rf-poster="img/talk-poster.jpg">
<img src="img/talk-lqip.jpg" alt="Keynote — opening talk">
<figcaption class="rf-text"><h2>Keynote</h2></figcaption>
</figure>
<!-- Vimeo — bare video ID; data-rf-poster is REQUIRED -->
<figure class="rf-slide"
data-rf-vimeo="76979871"
data-rf-poster="img/short-poster.jpg">
<img src="img/short-lqip.jpg" alt="Short film still">
<figcaption class="rf-text"><h2>Short Film</h2></figcaption>
</figure>
A full watch-page URL in data-rf-video also works —
youtube.com/watch?v=…, youtu.be/…,
youtube.com/embed|shorts/…, vimeo.com/… are all parsed — but
the bare-ID attributes are unambiguous and preferred.
Where the poster image comes from
data-rf-poster— author-supplied. Loaded through the normal image path (LQIP and frost-up included). Required for Vimeo, strongly recommended for YouTube: it is the only path with zero runtime network dependency on the provider, which also matters for GDPR-sensitive pages (no provider request until the visitor clicks play).- YouTube derivation (no poster given): the engine fetches the
video’s
maxresdefaultthumbnail, detecting and skipping YouTube’s gray 120×90 stub in favor ofhqdefault. - Vimeo derivation (no poster given): an oEmbed API call resolves
the thumbnail. This fails offline, for private videos, and at Vimeo’s whim — which
is exactly why
data-rf-posteris documented as required for Vimeo. The failure warns:Vimeo oEmbed failed for <id>; supply data-rf-poster.
Derivation (items 2–3) is a convenience fallback, not the recommended path. Supply
data-rf-poster and your page makes no provider requests at all until
playback — every shipped demo does exactly this.
Lightbox playback uses privacy-enhanced endpoints:
youtube-nocookie.com/embed for YouTube, player.vimeo.com for
Vimeo, both with autoplay=1 — the iframe fades in only after the poster
lands sharp. On strict-CSP sites see
the CSP notes for the frame-src
entries this needs.
CORS — images on a CDN
WebGL may only sample images the page is allowed to read. Refrakt requests
every image and video with crossorigin="anonymous", so:
- Same-origin files: nothing to do. Relative paths on your own host always work.
- Cross-origin files (CDN, object storage) must send
Access-Control-Allow-Origin— either*or your page’s origin. Without that header the browser blocks the texture upload and the slide takes the failure path (LQIP stays up, one console warning).
Enabling the header where you likely host media:
| Host | What to set |
|---|---|
| Amazon S3 / compatible | Bucket CORS rule: AllowedOrigins: ["*"],
AllowedMethods: ["GET"] |
| Cloudflare / most CDNs | Response-header rule adding
Access-Control-Allow-Origin: * for image/video paths |
| nginx | add_header Access-Control-Allow-Origin *; on the media
location |
| Apache | Header set Access-Control-Allow-Origin "*" in the media
directory |
YouTube’s thumbnail host serves CORS-clean images already; that path needs no configuration. If your CDN cannot send the header, serve slider media from the page’s own origin — everything else about Refrakt works identically.
Texture memory — auto-managed
You do not manage GPU memory; the engine does, against a flat 96 MiB texture budget shared by every instance on the page:
- Full-resolution textures are kept for the current slide ±
media.preloadneighbors; slides beyond that window become eviction candidates. - While the budget is exceeded, the least-recently-rendered candidate is evicted on the next idle frame. An evicted slide quietly reverts to its LQIP texture; if it re-enters the window, the full file re-fetches from HTTP cache and frosts back up. The current slide and its immediate neighbors are never evicted.
- Slide textures are capped at 2048 px regardless of DPR, and each costs
roughly
width × height × 4 × 1.34bytes (the 1.34 covers mipmaps) — a 1600×1000 image is ~8.6 MB of GPU memory. - On multi-instance pages the budget and the LRU list are global to the shared context, so ten product vitrines cannot stampede the GPU any harder than one hero.
Practical takeaway: follow the 1600 px guidance and roughly ten slides can be fully resident at once; beyond that the engine silently rotates textures and you will never notice on a working network.