Refrakt v1.0.0

Reference · Level 3

Every knob, event and edge case.

Refrakt is a zero-dependency WebGL2 optics engine for HTML sliders — real computed glass, dispersion and light, with a CSS fallback that keeps the same API. This is the complete reference.

This is the deep end — and you may not need it. Putting the slider on your page, using your own images, writing your own captions and choosing a look need none of what follows.

  1. Level 1 Start here Your pictures and your words in a working slider, about fifteen minutes, editing HTML only. Start there if you have not built one yet.
  2. Level 2 Customizing Colours, height, autoplay, controls, clickable slides, video: one copy-paste line each.
  3. Level 3 Reference manual Every option with its exact range, the API, events, theming, accessibility. You are here

Two more guides sit alongside these: the Optics Cookbook for whole ready-made builds, and the Media Guide for images and video. If something is behaving oddly, try Fixes first. The options table below is generated from the engine's own validation schema, so it cannot drift from what the code actually accepts.

Stuck on any of it? Email jawad.ahbab.turna@gmail.com — that reaches me, the developer who wrote the slider, not a ticket queue. Usually answered within a working day. What support covers.

01 Install

Two files and one script tag. Which of the two builds to use, and what each does the moment it loads.

Refrakt ships two builds and one stylesheet. Both builds are the same engine — the only difference is that the UMD build auto-initializes and the ESM build never does.

Script tag (no build step)

Drop the two files on your page. The UMD build calls Refrakt.auto() once on DOMContentLoaded (immediately if the document has already loaded), which initializes every element carrying a data-refrakt attribute. You write no JavaScript.

<link rel="stylesheet" href="lib/refrakt.css">

<div id="hero" data-refrakt="atelier">
  <figure class="rf-slide">
    <img src="img/look-1-lqip.jpg" data-rf-src="img/look-1.jpg"
         alt="Model in a pleated ivory coat">
    <figcaption class="rf-text"><h2>Atelier</h2></figcaption>
  </figure>
  <!-- more .rf-slide figures -->
</div>

<script src="lib/refrakt.umd.js"></script>

The UMD build also exposes the class as window.Refrakt, so you can construct instances by hand alongside auto-init. Constructing on an element that auto-init already bound warns once and hands you back the existing instance rather than making a second one.

In the download the built files sit in dist/refrakt.umd.js, refrakt.esm.js, refrakt.css. Copy them anywhere on your site; nothing in the engine resolves paths relative to itself, so only the paths in your <link> and <script> tags matter. The JS bundle deliberately injects no CSS: if you forget the stylesheet you get raw stacked images and no warning.

Bundler (ESM)

The package's exports map resolves refrakt to the ESM build and refrakt/css to the stylesheet. The ESM build never auto-runs — it checks document.currentScript, which is null for module execution — so you construct instances yourself, or call Refrakt.auto() when your app has mounted its DOM.

import Refrakt from 'refrakt';
import 'refrakt/css';

const slider = new Refrakt('#hero', {
  preset: 'prism-noir',
  glass: { ior: 0.12 },
  lightbox: { enabled: true },
});

slider.on('ready', ({ slideCount, mode }) => {
  console.log(`${slideCount} slides, running in ${mode} mode`); // 'gl' | 'css'
});

slider.on('change', ({ index, previousIndex, source }) => {
  console.log(index, previousIndex, source); // source: 'drag' | 'api' | 'autoplay' | …
});

TypeScript definitions ship at types/refrakt.d.ts and are wired into the same export. Refrakt.version returns the build's semver string.

02 The markup contract

The exact HTML a slide must contain. Four requirements; everything else on the page is yours.

Refrakt enhances real DOM. What you author is what search engines index, what screen readers read, and what a visitor sees before the script runs. The engine's requirements are narrow and exact.

<div id="hero" data-refrakt="frost">

  <figure class="rf-slide">
    <img src="img/plate-1-lqip.jpg" data-rf-src="img/plate-1.jpg"
         alt="Cut crystal tumbler on green felt">
    <figcaption class="rf-text">
      <h2>Plate One</h2>
      <p>Hand-cut lead crystal, 1962.</p>
    </figcaption>
  </figure>

  <figure class="rf-slide">
    <img src="img/plate-2-lqip.jpg" data-rf-src="img/plate-2.jpg"
         alt="Decanter, backlit">
    <figcaption class="rf-text"><h2>Plate Two</h2></figcaption>
  </figure>

</div>

What the engine actually requires

  • The container is any block element. It needs a real size from your CSS — height: 100vh, a fixed height, or aspect-ratio. Slides are absolutely positioned at init, so a container with no explicit height collapses to zero and you see nothing.
  • Slides are found with :scope > .rf-slide — direct children only. A wrapper <div> between the container and the slides breaks detection completely. At least one is required.
  • The class is load-bearing, the tag is not. <figure class="rf-slide"> is the convention (and what <figcaption> pairs with), but the engine matches on .rf-slide.
  • Slide order is source order. Indices are 0-based.
  • Media is the first <img> inside the slide. Its inline src is the placeholder; data-rf-src is the full-resolution file. Video and provider slides use per-slide attributes on the <figure> instead — see Media.
  • .rf-text is one optional element per slide. At init it is moved into a synced .rf-text-layer that tracks the rendered scene every frame; the engine only writes transform, opacity and visibility on it. The content stays real, selectable, crawlable DOM, and destroy() returns each node to its original parent and sibling position.
  • Original media is hidden, not removed. Your <img>/<video> elements get visibility: hidden (so they keep decoding and stay in the accessibility tree) while the WebGL canvas paints the pixels. Anything inside .rf-text is left alone.

Zero slides, or a selector that matches nothing

Neither throws. The constructor emits exactly one console.warn and returns an inert stub instance: every method is a no-op that returns this, getIndex() and getProgress() return -1, and no DOM is touched.

new Refrakt('#does-not-exist');
// [Refrakt] element not found for "#does-not-exist"; instance is inert

new Refrakt(emptyDiv);
// [Refrakt] container has no .rf-slide children; instance is inert

Changing slides later

Add or remove .rf-slide children in the DOM, then call rebuild(). It is a destroy() plus re-init on the same element: the current index is captured first and reused as the start index, and every handler registered with .on() is preserved across the rebuild, so ready fires again into your existing subscribers.

03 Data attributes

Setting options in HTML rather than JavaScript — and the order in which the four sources of configuration override one another.

Container attributes

Read once, at new Refrakt() / Refrakt.auto().

AttributeValueBehavior
data-refraktempty, or a preset nameMarks the element for Refrakt.auto(). A non-empty value is shorthand for data-rf-preset. Leave it empty for the client-safe defaults.
data-rf-presetpreset nameNames the preset. If both are present, this one wins.
data-rf-optionsJSON stringPer-instance overrides, parsed with JSON.parse inside a try/catch. A parse failure logs data-rf-options ignored: <error> and the whole attribute is dropped — the preset still applies.
<div id="hero"
     data-refrakt="atelier"
     data-rf-options='{"motion": {"autoplay": {"enabled": true, "interval": 5500}},
                       "nav": {"counter": true, "spectrum": false},
                       "hash": true}'>
  <!-- .rf-slide children -->
</div>

Note the quoting: the attribute is wrapped in single quotes so the JSON can use its required double quotes. Single-quoted JSON keys are the most common cause of the parse warning.

The merge chain

Four sources are deep-merged in this order — later wins, leaf by leaf:

  1. Defaults — the full option tree. This is the client-safe preset.
  2. Preset — whatever the named preset declares. Anything it does not declare falls through.
  3. data-rf-options — the page-level override.
  4. Constructor options — the object passed to new Refrakt(el, options).

Plain objects merge key by key; arrays and scalars replace wholesale. The merged result is then validated leaf by leaf against the schema:

  • Out-of-range numbers are clamped to the nearest bound, with one warn (glass.ior 0.9 clamped to 0.35).
  • Wrong-typed values fall back to the default, with one warn.
  • Unknown paths are dropped, with one warn (unknown option "glass.iro" ignored).

Every warning fires at most once per instance per cause. A bad config produces a different look, never a broken slider — the engine never throws on configuration.

Which preset name wins

The preset key is resolved before the merge, from the first source that supplies one: options.presetdata-rf-options.presetdata-rf-presetdata-refrakt'client-safe'. An unregistered name warns (unknown preset "…"; using defaults) and the instance runs on defaults rather than failing.

A shipped preset's options never re-declare their own name, and data-refrakt / data-rf-preset are used for lookup only — they are not written into the config tree. So with data-refrakt="atelier" the atelier options are genuinely applied, but getOption('preset') still reads 'client-safe'. If you need that read to be truthful (a settings panel, analytics), declare the name explicitly: data-rf-options='{"preset": "atelier"}'.

Per-slide attributes

On each .rf-slide. Media sources are mutually exclusive and resolve in this priority: data-rf-youtube / data-rf-vimeodata-rf-videodata-rf-srcsetdata-rf-src → the inline <img src>.

AttributeGoes onValue
data-rf-srcthe <img> or the slideFull-resolution image URL. The inline src is then the LQIP.
data-rf-srcsetthe <img> or the slidesrcset string, width descriptors only. Wins over data-rf-src.
data-rf-videothe slideSelf-hosted video URL — uploaded as a live GL texture, so the video itself refracts.
data-rf-posterthe slidePoster frame for any video or provider slide.
data-rf-youtubethe slideBare YouTube video ID.
data-rf-vimeothe slideBare Vimeo video ID.

04 Options reference

Every setting the engine accepts, with its type, default and exact range. Generated from the engine’s own validation schema. Filter it; do not read it end to end.

Every row below is generated from the engine's own validation schema — the same table setOption() clamps against at runtime, so the types, ranges and defaults here cannot drift from the code. Options marked live apply on the next frame; those marked rebuild() are stored and take effect on the next rebuild() call. Values outside a range are clamped (with one console warning), never rejected.

70 options

layout

The track itself — hero or lens, looping, axis, where it opens.

PathTypeDefault AppliesWhat it does
layout.mode enum'hero' | 'lens' 'hero' rebuild() 'hero' fills the container with one slide; 'lens' shrinks one slide of travel to (1 − gap) of the container so neighbours sit at the edges, and feeds the shader a cell-distance so those off-centre cells pick up glass and blur as they approach.
layout.gap number00.5 0.08 live In lens mode, the fraction of the container the neighbours steal: one slide of travel becomes (1 − gap) of the container, so side cells peek in further and a drag covers more ground. Ignored in hero mode.
layout.sideScale number0.51 0.86 live How small the neighbouring lens cells are drawn — 0.86 = 14% shrink one slide out. Only the CSS fallback renderer reads it; the WebGL path expresses lens depth as glass + blur on the side cells instead, so it has no visible effect there.
layout.loop booleantrue | false true live Whether the track wraps end to end. Off, the ends rubber-band against a wall and the arrow buttons disable on the first and last slide.
layout.startIndex integer0 0 rebuild() Which slide the instance opens on (0-based, clamped to the slide count). A hash deep-link beats it when hash is on, and rebuild() overwrites it with the current index.
layout.axis enum'x' | 'y' 'x' live 'x' drags and wheels horizontally, 'y' vertically; it also swaps which arrow keys navigate and flips the container's touch-action so the other axis still scrolls the page.

glass

The optical surface in front of the photo. One kind at rest; the wedge band carries every transition.

PathTypeDefault AppliesWhat it does
glass.kind enum'reeded' | 'wedge' | 'frost' | 'none' 'reeded' live Which element sits in front of the photo at rest: 'reeded' flutes, 'frost' (see frost.mode), 'wedge' (no rest state at all — the prism only exists while you're moving) or 'none' (pin-sharp at rest). The sweeping wedge band that carries every transition runs for all four.
glass.ior number00.35 0.08 live Master refraction strength — how far the glass's slope actually pushes the pixels underneath it, scaling reeded, frost and wedge alike. Past ~0.2 straight lines in the photo start visibly bending.
glass.reeded.frequency number496 24 live How many vertical flutes fit across the pane. Low reads as thick architectural ribs; high becomes fine corduroy that starts to alias on busy photography.
glass.reeded.depth number01 0.07 live How deep the flutes cut. Past ~0.3 real photos start shredding into vertical sawtooth fins at rest — the shipped hero presets sit at 0.05–0.07 for exactly that reason.
glass.reeded.sheen number01 0.35 live How brightly the flute ridges catch the specular highlight (it multiplies the glint, on the reeded kind only). Turn it up for wet, glossy ribs; 0 kills the highlight and leaves plain distortion.
glass.wedge.angle number045 12 live The tilt of the prism band that sweeps across during a transition, in degrees off the travel axis — 0 is a straight edge-on wipe, 45 a full corner-to-corner diagonal.
glass.wedge.width number0.050.5 0.2 live How thick that sweeping band is, as a fraction of the pane. Narrow reads as a hard crease travelling across the image; wide smears most of the frame at once.
glass.wedge.gain number02 0.35 live How hard the band shears the image, multiplied by your drag velocity — at zero velocity the wedge contributes literally nothing, so the settled frame stays pin-sharp no matter how high this is. Above ~1 a fast flick tears the photo apart along the band.
glass.frost.scale number20600 180 live Frost grain size, in noise cells across the pane. Low is blobby smoked glass; high is fine etched bathroom-window texture.
glass.frost.coverage number01 0.7 live How much of the pane the frost actually claims (it thresholds a low-frequency mask). At 1 the whole pane ices over; around 0.3 it survives only as scattered patches.
glass.frost.mode enum'transition' | 'rest' 'transition' live 'transition' (default) makes frost a veil — nothing at rest, clouding over as slides cross, resolving pin-sharp once settled; 'rest' keeps the old permanently-frosted pane whose condensation you wipe clear with light.clearRadius. Only applies when glass.kind is 'frost'.
glass.frost.dissolve number01 0.6 live The same cloud-over-then-resolve veil, but for the non-frost kinds (reeded / wedge / none): 0 gives a clean optical transition, 1 fully fogs the frame mid-cross before it snaps sharp. Ignored on hero frost (which uses frost.mode) and under reduced motion.

dispersion

Chromatic split — how far red and blue pull off green through the bent glass.

PathTypeDefault AppliesWhat it does
dispersion.amount number01 0.08 live The resting chromatic split — how far red and blue are pulled off green wherever the glass is already bending light. It shows nothing on a flat pane, and nothing at all on the 'lite' quality tier, which takes a single texture tap.
dispersion.velocityGain number02 0.3 live How much extra split drag speed buys you; this is the knob that makes a flick smear into rainbow while a still frame keeps only whatever amount set.
dispersion.maxTear number01 0.15 live Hard ceiling on amount + velocityGain·speed — the split can never exceed it however hard you throw the slider. Keep it low on client photography; at 1 a fast drag is pure prism damage.

light

The specular light source: where it lives, how bright, and whether it drifts or follows the cursor.

PathTypeDefault AppliesWhat it does
light.x number01 0.7 live Horizontal home of the specular light, 0 = left edge, 1 = right edge of the container. Drift and followPointer move the light relative to this anchor rather than replacing it.
light.y number01 0.25 live Vertical home of the specular light, 0 = top, 1 = bottom. Same anchor role as light.x.
light.intensity number01.5 0.6 live How bright the glint and caustic burn. It surges with drag speed (up to 2.5× this value, hard-clamped at 2), so a setting that looks calm at rest can clip to white on a fast flick.
light.drift booleantrue | false true live Whether the light wanders on its own while the slider is idle — a slow Lissajous of ±0.12 around the light.x/y anchor. Off pins it dead still and lets the render loop park completely when nothing else is moving.
light.driftSpeed number00.5 0.05 live How fast that idle wander advances (radians/sec of the drift phase): at the 0.05 default a full figure-eight takes about two minutes, while 0.5 is a visibly restless swim.
light.followPointer number01 0 live How far the light is pulled toward the cursor — 0 ignores the pointer, 1 locks the light to it. The mix eases in and out over ~0.28 s, so entering or leaving the slider glides rather than snapping.
light.clearRadius number01 0 live How strongly the light wipes a clear patch through frost, as if you'd rubbed the condensation with a sleeve. Only visible where frost actually exists (kind 'frost', mostly mode 'rest'), and best paired with followPointer so the patch tracks the cursor.

exposure

The photographic pass laid over everything: settle flash, bloom, grain, breathing.

PathTypeDefault AppliesWhat it does
exposure.settle booleantrue | false true live The little overexposure kick when a slide lands — a damped 0.8 s flash that decays like a camera settling. It also holds back the public 'transitionend' event until the envelope finishes.
exposure.settleAmount number01 0.25 live Height of that landing flash: peak brightness is 1 + this, so 0.25 is a soft breath and 1.0 a hard develop-from-white bloom on every single arrival.
exposure.bloom number01 0.15 live How readily bright pixels blow out — it lowers the luma threshold at which highlights start feeding back into themselves (up to +35% on the brightest areas). Whites go milky and glowing as you turn it up.
exposure.grain number00.5 0.06 live Animated film grain added to every pixel. 0.06 is a fine tooth; past ~0.2 it reads as heavily pushed film and starts eating fine detail.
exposure.breathing booleantrue | false false live A slow ±4% brightness oscillation on a 6-second cycle that never stops, giving an idle slider a live, lit feel — but it also keeps the render loop awake at ~10 fps while the slider is on screen, so it is not free.

motion

Gestures and physics — drag, wheel, keyboard, snap, inertia, autoplay.

PathTypeDefault AppliesWhat it does
motion.drag booleantrue | false true live Whether the pointer can grab and drag the track at all. Off, clicks, the lightbox, wheel and keyboard still work — only the grab is gone.
motion.wheel booleantrue | false true live Whether wheel and trackpad gestures move the track: trackpad deltas drive a synthetic drag, mouse detents step one slide with a 180 ms lockout, and at a non-looping end the page is allowed to scroll instead of being jailed.
motion.keyboard booleantrue | false true live Whether arrows / Home / End (plus Space to toggle autoplay, when autoplay is on) drive the slider while it holds focus.
motion.snap booleantrue | false true live Whether the slide magnet pulls during a fling. Off, the throw free-coasts and is only caught by the final seek at the end, which feels loose and skate-like instead of detented.
motion.snapStrength number0.11 0.85 live Stiffness of that magnet (spring constant 68 → 320 across the 0.1–1 range). Low is a lazy suggestion of a slide; 1 grabs the nearest slide almost the instant you let go.
motion.inertia number01 0.55 live How long the track keeps gliding after release — it sets the decay rate (λ = 10 − 8·inertia), so 0 stops almost on the spot and 1 coasts freely for about half a second before the seek catches it.
motion.maxStep integer020 1 live The most slides one drag or flick may advance: 1 (default) lands a single gesture at most one slide over and rubber-bands if you keep dragging, 0 removes the cap for unlimited momentum. Doesn’t affect goTo / next / prev / autoplay, which stay exact.
motion.autoplay.enabled booleantrue | false false live Whether the deck advances on its own. Turning it on always mounts a visible pause button (WCAG 2.2.2) that no nav option can remove.
motion.autoplay.interval integer150060000 6000 live Milliseconds of rest between automatic advances; the timer only counts down while the slider is genuinely idle, at least 35% on screen, the tab is visible, and it isn’t hovered or paused.
motion.autoplay.pauseOnHover booleantrue | false true live Whether the pointer entering the container holds the autoplay timer. Off, slides keep advancing under the cursor.

media

Decoding and the load reveal. Every key here is rebuild-only.

PathTypeDefault AppliesWhat it does
media.lazy booleantrue | false true rebuild() true decodes only the current slide plus its preload neighbours, and waits until the slider is actually on screen; false decodes every slide’s full-res texture at init. Needs rebuild().
media.frostUp booleantrue | false true rebuild() The load reveal: while the full-res texture is in flight the tiny LQIP is shown behind procedural frost, which clears over 600 ms as the real image develops in. Off, the full image just hard-swaps.
media.preload integer03 1 rebuild() How many neighbour slides each side are decoded ahead of the current one (0–3). Higher spends texture memory and hits the 96 MB LRU budget sooner; 0 means each slide only decodes once you reach it.
media.video.muted booleantrue | false true rebuild() Mute state for self-hosted video — but it is only honored when media.video.autoplay is false, because an autoplaying video is force-muted (no browser will start it otherwise).
media.video.autoplay booleantrue | false true rebuild() Whether the committed centre slide’s video plays by itself (it always pauses when the slide leaves centre, the slider scrolls away, or the tab hides). Off, the video sits on its first frame until the lightbox opens it.
media.video.loop booleantrue | false true rebuild() Whether a self-hosted video restarts from the top when it reaches the end.
media.video.playsinline booleantrue | false true rebuild() Sets playsinline on the pooled <video> elements so iOS refuses to hijack them into its fullscreen native player. Leave it on unless you actually want that takeover.

text

The synced caption layer.

PathTypeDefault AppliesWhat it does
text.sync booleantrue | false true live Whether the caption layer tracks the track continuously (true) or snaps to the nearest slide (false) — off, the text jumps at half-slide crossings while the image keeps sliding under it.
text.parallax number01 0.15 live How much faster than the image the caption travels (gain is 1 + this): 0 glues the text to the photo, 1 moves it at double speed for a hard depth split.
text.rgbEcho booleantrue | false false live Velocity-driven chromatic echo on h1–h3 inside .rf-text — invisible at rest, splitting into RGB ghosts up to 8 px as you drag. Free when settled, and force-off under reduced motion.

nav

Which chrome gets mounted. The autoplay pause button is not on this list — it is never optional.

PathTypeDefault AppliesWhat it does
nav.arrows booleantrue | false true live Mount the prev/next chevron buttons — real <button> elements, auto-disabled at the first and last slide when not looping.
nav.spectrum booleantrue | false true live Mount the spectrum bar: one dot per slide, rainbow-dispersed when inactive and condensing to solid ink for the current one; the active dot flips mid-drag, not on commit.
nav.counter booleantrue | false false live Mount the '03 / 08' fraction counter (tabular numerals, aria-hidden — the live region already announces position).
nav.editorialMenu enumtrue | false | 'blend' false live Mount the giant numbered slide menu, labelled from each slide's first heading; 'blend' mounts the same menu with mix-blend-mode: difference so it inverts against the photo underneath.
nav.progressRule booleantrue | false false live Mount the thin continuous progress rule that fills with the live track position — transform-only, so it follows a drag frame for frame instead of stepping per slide.

lightbox

The fullscreen view and the de-refract open.

PathTypeDefault AppliesWhat it does
lightbox.enabled booleantrue | false false live Whether tapping a slide opens the fullscreen lightbox. It gates only that built-in click trigger — openLightbox() always works, and YouTube/Vimeo slides force their own open because playback has nowhere else to go.
lightbox.deRefract booleantrue | false true live The signature open: over 450 ms the glass uniforms (flute depth, wedge gain, dispersion, frost coverage) sweep to zero while the slide FLIPs up to fullscreen, so the image irons flat as it grows. Off — or under reduced motion, or in CSS fallback — you get the FLIP alone.

a11y

Reduced motion, the live region, and the one configurable UI string.

PathTypeDefault AppliesWhat it does
a11y.reducedMotion enum'auto' | 'always' | 'never' 'auto' live 'auto' follows the OS prefers-reduced-motion setting, 'always' forces the reduced path on, 'never' ignores the OS. Reduced kills the wedge shear, dispersion, light drift, breathing and the settle flash, and turns every transition into a plain dissolve.
a11y.announceSlides booleantrue | false true live Whether the polite live region announces 'Slide 3 of 8: <heading>' 150 ms after each change. Autoplay-driven changes are never announced either way.
a11y.label stringnon-empty 'Image carousel' live The container’s aria-label. It is skipped entirely if you already authored an aria-label or aria-labelledby on the container, and it is the only configurable UI string in v1.

quality

The FPS governor’s brief: what to defend, and how far it may go.

PathTypeDefault AppliesWhat it does
quality.tier enum'auto' | 'high' | 'lite' 'auto' live 'high' pins the full shader, 'lite' drops to a single texture tap (no chromatic split, no caustic) and — on a page where every instance is lite — a 1.5 DPR cap, and 'auto' lets the FPS governor demote you to lite if the frame budget keeps blowing.
quality.maxDPR number13 2 live Ceiling on the device-pixel-ratio the canvas renders at (and on the width used to pick from srcset). 1 is the cheapest; 3 is retina-sharp and roughly 9× the fragment work.
quality.fpsFloor integer2458 48 live The frame rate the governor defends: when the 95th-percentile frame time stays worse than 1000/fpsFloor for three 60-frame windows it steps the DPR down, then (on ‘auto’) drops to the lite tier, recovering one step per 30 clean seconds.

Top level

Bare top-level keys — no group.

PathTypeDefault AppliesWhat it does
preset string 'client-safe' rebuild() Which registered preset bundle is merged under your own options (defaults ← preset ← data-rf-options ← constructor); an unknown name warns once and falls through to the factory defaults, and changing it needs rebuild().
hash booleantrue | false false live Deep-link the current slide as #<container-id>-<n>, 1-based — replaceState while moving, pushState on settle, so the Back button walks the slides. Requires an id on the container; without one it silently does nothing.
forceFallback booleantrue | false false rebuild() Skip WebGL2 entirely and construct in CSS fallback mode; the 'fallback' event fires with reason 'forced'. API, events and nav chrome are identical — this is how you test the no-WebGL experience.
debug booleantrue | false false rebuild() Accepted, validated and stored on the config, but nothing in the shipped engine source reads it — it is currently a no-op.

05 Presets

The eight named looks, exactly what each one sets, and how to register your own.

Eight bundles ship with the engine. A preset is nothing but a partial options object merged under yours — defaults ← preset ← data-rf-options ← constructor — so anything a preset does not declare falls through to the defaults, and anything you declare beats it. Presets carry no colours: the ink/paper pairing named below is yours to set (see Theming).

Each one is running live on its own page.

01

Client-Safe

The factory default — a safe, subtle starting point for any site whose client photography must stay readable and undistorted (agency work, general business, anything you hand off).

glass.kind: reededglass.ior: 0.08glass.reeded.depth: 0.07dispersion.maxTear: 0.15

02

Atelier

Fashion lookbooks and design portfolios that want a lens carousel — the centre slide sharp, its neighbours dissolving into deep flutes; pair with warm paper and near-black ink.

layout.mode: lensglass.kind: reededglass.reeded.depth: 0.65glass.frost.dissolve: 0.85

03

Prism-Noir

Dark editorial sites at full volume — a max-tear wedge prism with heavy dispersion and a giant numbered menu instead of arrows; pair with white ink on black paper.

glass.kind: wedgeglass.wedge.gain: 1.6dispersion.maxTear: 1.0nav.editorialMenu: true

04

Boardroom

Corporate and SaaS heroes that need a calm, self-running deck — no rest-state distortion at all, just a faint drag shimmer and a slow autoplay; pair with dark ink on white paper.

glass.kind: nonedispersion.maxTear: 0.08glass.frost.dissolve: 0.75motion.autoplay.interval: 7000

05

Frost

Hospitality, food and drink sites — slides cloud over with heavy procedural frost as they cross, then resolve pin-sharp; add glass.frost.mode: 'rest' to keep the pane on and let the pointer light wipe it clear like condensation.

glass.kind: frostglass.frost.coverage: 0.9light.followPointer: 0.35light.clearRadius: 0.5

06

Darkroom

Photography portfolios — every settle develops the frame up from white under film grain, with a slow breathing autoplay; pair with near-black paper (#0a0a0a).

exposure.settleAmount: 0.5exposure.grain: 0.18exposure.breathing: truemotion.autoplay.interval: 5000

07

Spectra

Type-led editorial and event sites, where RGB-echo headings fire in sync with a wide wedge band sweeping across the slide.

glass.wedge.width: 0.3dispersion.amount: 0.5text.rgbEcho: truenav.editorialMenu: true

08

Eclipse

Product pages and luxury reveals — a pointer-driven light source relights deep glossy flutes in real time, so the object appears to turn under your cursor.

glass.reeded.frequency: 28glass.reeded.sheen: 0.8light.followPointer: 0.6exposure.bloom: 0.5

06 API

Driving the slider from your own JavaScript: constructing instances, moving between slides, changing settings live, tearing down cleanly.

Every mutating method returns the instance, so calls chain. The readers return values, and destroy() returns nothing. After destroy() every method is a warn-once no-op — nothing throws.

Statics

new Refrakt(elementOrSelector: HTMLElement | string, options?: RefraktOptions)

Binds one slider to a container element (or the first match of a CSS selector); constructing on an already-bound element warns and returns the existing instance, and a missing element or a container with zero :scope > .rf-slide children warns and yields an inert stub whose methods all no-op. Config merge order is defaults <- preset <- data-rf-options <- ctor options; preset name resolves from options.preset, data-rf-options.preset, data-rf-preset/data-refrakt, else 'client-safe'.

Refrakt.auto(root: ParentNode = document): Refrakt[]

Constructs an instance for every [data-refrakt] element under root that is not already bound, and returns only the newly created instances; the UMD build calls this automatically on classic-script execution (document.currentScript non-null), on DOMContentLoaded if the document is still loading.

Refrakt.registerPreset(name: string, json: RefraktPresetFile | RefraktOptions): undefined

Registers or overwrites (with one warn) a preset under name, accepting either a full {meta, options} preset file or a bare options object; options are sanitized against the schema at registration (clamped, unknown keys dropped with a one-time warn) and the stored entry is deep-frozen. Returns undefined. Invalid/empty name warns and is ignored.

Refrakt.presets: Readonly<Record<string, { meta: object, options: object }>>

Static getter returning a frozen shallow snapshot of the preset registry (the 8 built-ins — client-safe, atelier, prism-noir, boardroom, frost, darkroom, spectra, eclipse — plus anything registered via registerPreset).

Refrakt.version: string

Static getter returning the build semver string (the __VERSION__ build constant, falling back to '1.0.0').

Instance methods

next()

returns this (chainable)

Advances one slide with source 'api', wrapping when layout.loop is true and stopping silently at the end otherwise (no event at the end stop).

prev()

returns this (chainable)

Goes back one slide with source 'api', wrapping when layout.loop is true and stopping silently at the start otherwise.

goTo(index: number, opts?: { instant?: boolean })

returns this (chainable)

Seeks to a slide index with source 'api'; {instant: true} jumps without animation (still emitting transitionstart/change/transitionend in the same frame). A non-numeric/non-finite index warns once and is ignored; an out-of-range index is wrapped when looping or clamped otherwise (with a one-time warn); calling it with the current index while genuinely at rest is a no-op that emits nothing.

getIndex()

returns number

Returns the committed slide index, or -1 on a stub / destroyed instance (and -1 before the integrator exists).

getProgress()

returns number

Returns the continuous position in slide units (wrapped [0, n) when looping, clamped [0, n-1] otherwise), or -1 on a stub / destroyed instance.

setOption(path: string, value: unknown)

returns this (chainable)

Writes one option by dot path; the value is validated/clamped (out-of-range clamps and unknown paths warn once). Live keys are applied immediately (uniform table re-applied, plus targeted re-application for text.*, nav.*, a11y.label, a11y.reducedMotion, motion.* autoplay state, layout.axis touch-action and quality.maxDPR DPR/backing-store resize); rebuild-only keys are stored and take effect on the next rebuild().

setOption(batch: RefraktOptions)

returns this (chainable)

Same as the dot-path form but flattens the object to leaf paths and applies each one in turn (a deep-merge batch write).

getOption(path: string)

returns unknown (deep-cloned value; undefined for an unknown path or on a stub/destroyed instance)

Reads the current effective, post-clamp value at a dot path from the resolved config.

play()

returns this (chainable)

Sets motion.autoplay.enabled = true, clears the spacebar pause, and re-evaluates the autoplay gate (idempotent). The timer still only runs when the slider is >=35% visible, the page is not hidden, the lightbox is closed and pauseOnHover/hover does not veto it.

pause()

returns this (chainable)

Sets motion.autoplay.enabled = false and stops the autoplay timer (idempotent).

openLightbox(index?: number)

returns this (chainable)

Opens the lightbox on index, defaulting to the current slide when omitted (null/undefined); an out-of-range index is wrapped/clamped, and a slide with no full-size media (no fullSrc/videoSrc/posterSrc/youtube/vimeo) warns once and does not open.

closeLightbox()

returns this (chainable)

Closes the lightbox and restores focus (idempotent; safe when nothing is open).

on(name: string, fn: (e) => void)

returns this (chainable)

Subscribes to an instance event; a non-function handler warns and is ignored, an unknown event name warns once (but the subscription is still recorded and forwarded), duplicate (name, fn) pairs are not double-recorded, and subscribing to 'ready' after it already fired replays the cached payload synchronously. Subscriptions are recorded on the wrapper so they survive rebuild() and the runtime GL->CSS fallback swap.

off(name: string, fn?: (e) => void)

returns this (chainable)

Unsubscribes one handler, or every handler for that event name when fn is omitted.

destroy()

returns undefined (NOT chainable)

Emits 'destroy' synchronously, clears all handlers, tears down listeners/observers/media/GL registration/layers/aria/lightbox and restores the original DOM (slides back in place, media visibility restored, inline position/touch-action and the rf-root class reverted). After this every method is a warn-once no-op; getIndex()/getProgress() return -1 and getOption() returns undefined.

rebuild()

returns this (chainable)

Tears down and re-initializes against the current DOM, preserving event handlers (the emitter is kept, so no 'destroy' is emitted) and carrying the current index into layout.startIndex; this is what applies options marked “requires rebuild()” and picks up added/removed slides. The cached 'ready' payload is dropped and 'ready' fires again.

07 Events

What the slider tells you, and when. One table — every payload listed is the real shape you receive.

Subscribe with .on(name, fn) and drop with .off(name, fn). Every payload carries at least instance (the public Refrakt) and index (the committed index at emit time); the fields below are what each event adds on top. Subscriptions live on the wrapper, so they survive rebuild() and the runtime GL → CSS fallback swap.

EventPayloadWhen it fires
ready { instance, index, slideCount, mode } Fires once after the first frame renders. slideCount is the number of ingested .rf-slide children; mode is 'gl' | 'css'. The wrapper caches this payload, so late on('ready', fn) subscribers are replayed it immediately. Base fields: instance (the public Refrakt) and index (getIndex() at emit time).
change { instance, index, previousIndex, source } The committed index changed — emitted exactly once per half-integer boundary crossing, stepping index-by-index, and forwarded only after the media window, nav, aria announce and hash replaceState have been updated. NOTE: this is the only event whose own payload carries index (the wrapped committed index at that crossing), so it shadows the emit() base index; in a multi-crossing batch the two can differ. previousIndex is the wrapped index before that step; source is 'drag' | 'api' | 'autoplay' | 'keyboard' | 'wheel' | 'hash'.
drag { instance, index, progress, velocity } Emitted whenever the drag position actually moves while a pointer/wheel gesture is active — including the threshold-cross that opens the gesture (velocity 0), wheel deltas, and every frame the drag is live. progress is the continuous position in slide units (getProgress()), velocity is the estimator’s slides/second reading.
transitionstart { instance, index, from, to, source } An animated index movement began — release/inertia, API seek, autoplay advance, keyboard, or hash. from is the wrapped committed index at the start, to the target index, source one of 'drag' | 'api' | 'autoplay' | 'keyboard' | 'wheel' | 'hash'. A retarget mid-flight does NOT emit a second transitionstart.
transitionend { instance, index, from, to } The integrator settled on its final target — exactly one per transitionstart. No source field. In GL mode, when exposure.settle is on, settleAmount > 0 and reduced motion is off, delivery is DEFERRED: the instance holds the payload and re-emits it only once the 0.8 s exposure settle envelope completes; otherwise it is emitted immediately.
mediaplay { instance, index, media } A slide's self-hosted <video> started or resumed playing — in GL mode from the pooled video source, in CSS mode from the element's own play event. media is the HTMLVideoElement. Internally this also marks video as playing and wakes the render loop.
lightboxopen { instance, index } The lightbox open timeline completed. No extra fields beyond the base — the payload is literally {} merged onto { instance, index }. Internally it also pauses autoplay for as long as the lightbox is open.
lightboxclose { instance, index } The lightbox close timeline completed and focus was restored. No extra fields beyond the base. Internally it re-evaluates the autoplay gate.
fallback { instance, index, reason } The instance is running in CSS fallback mode instead of WebGL2. reason is 'no-webgl2' (probe or context acquisition failed), 'forced' (options.forceFallback) or 'context-lost' (unrecovered GL context loss, migrated at runtime by the wrapper). Always emitted BEFORE the 'ready' event of the CSS instance.
destroy { instance, index } Emitted synchronously at the start of destroy(), before teardown, while the DOM and state are still intact — the last event the instance ever emits; handlers are cleared immediately after. No extra fields beyond the base. rebuild() does NOT emit it.

08 Media

Preparing images and video: sizes, formats, the loading pipeline, and the GPU memory behind it all.

Everything the engine draws — images, video frames, provider posters — becomes a WebGL texture sampled through the optics shader. Slides are cover-fit: scaled to fill the container, cropped on the overflowing axis, exactly like CSS object-fit: cover.

Images

  • 1600 px on the longest edge is the sweet spot for full-bleed heroes.
  • Textures are capped at 2048 px internally, whatever the source file or the device pixel ratio. Pixels beyond that cost bytes and decode time and are never displayed. Do not ship 4000 px files.
  • Keep one aspect ratio per slider. Cover-fit hides mismatches by cropping, but consistent framing keeps focal points and captions predictable.
  • JPEG at quality 75–82. The optics pass lays grain and light over the image, which masks compression far better than a static viewer would.

Responsive sources

data-rf-srcset takes the same syntax as native srcset, but only width descriptors (800w). Density descriptors (2x) do not parse 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 runs once, when the slide enters the preload window. The target width is the container's CSS width × min(devicePixelRatio, quality.maxDPR) (DPR capped at 2 by default). The smallest candidate ≥ target wins; if none is large enough, the largest available is used. Resizing the window afterwards does not re-fetch a bigger candidate — selection is deliberately one-shot so a window drag-resize cannot trigger a download storm.

Lazy loading

On by default. Only the current slide ± media.preload neighbours (default 1, range 0–3) are fetched, and the window self-advances mid-drag — it re-centres on every half-slide crossing, so the incoming slide is already decoding before you release. An offscreen slider defers its first decode entirely until it scrolls into view. Set media.lazy: false to eager-load every slide.

The frost-up reveal

Loading is part of the look, not something to hide. The inline <img src> is the low-quality placeholder by contract:

  1. The LQIP uploads immediately and renders behind procedural frost — the frost surface is 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.
  2. The full file decodes off the critical path. The texture swap is gated to an idle frame, so it never lands mid-gesture and never pops.
  3. On swap the frost veil melts over 600 ms on cubic-bezier(0.33, 0, 0.2, 1), revealing the sharp image — condensation clearing from glass. Under reduced motion it becomes a 200 ms linear fade.

Keep the LQIP at 64 px or less on the long edge, same aspect ratio, quality ~50 — 1–2 KB, and the first paint is effectively free. media.frostUp: false disables the choreography: the LQIP renders as-is and the full image swaps in hard.

If a slide has only an inline src and no data-rf-src, that file is treated as both placeholder and final image. Fine for a thumbnail strip; wrong for a hero.

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.

<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>
  • MP4 / H.264. The one codec every supported browser decodes. HLS (.m3u8) plays only where natively supported (Safari/iOS) — no hls.js is bundled, and the zero-dependency rule means there never will be. Elsewhere the slide falls back to its poster.
  • Muted autoplay only. muted and playsinline are forced whenever autoplay is in effect, because unmuted autoplay is blocked by every modern browser. If a browser still refuses the first play(), the poster stays up and playback retries on the visitor's next interaction.
  • A video plays only while its slide is the committed centre slide and the tab is visible. Leaving centre pauses it — resume, not restart. Off-window videos cost nothing per frame.
  • A shared pool of at most 4 <video> decoders serves every instance on the page (mobile Safari caps concurrent decoders). With more simultaneous video slides than that, the furthest-from-centre slides show their poster stills — which is why data-rf-poster is not optional.
  • Defaults live under media.video: { muted: true, autoplay: true, loop: true, playsinline: true }.

Each time a self-hosted video actually starts playing (first autoplay and every resume) the instance emits mediaplay with { media: HTMLVideoElement }.

YouTube and Vimeo — the poster pattern

The 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. Provider slides instead use the poster pattern — a still image renders inside the optics (it refracts, tears and frosts like any slide, and gets a .rf-play-badge), and clicking it opens playback in a lightbox iframe.

<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>

Provider slides force their own lightbox on even when lightbox.enabled is false — playback has nowhere else to go. Lightbox playback uses the privacy-enhanced endpoints: youtube-nocookie.com/embed and player.vimeo.com.

If you omit data-rf-poster, the engine derives one: for YouTube it probes maxresdefault and detects the grey 120×90 stub, falling back to hqdefault; for Vimeo it makes an oEmbed API call, which fails offline, on private videos, and at Vimeo's discretion (Vimeo oEmbed failed for <id>; supply data-rf-poster).

Derivation is a convenience fallback, not the recommended path. Always supply data-rf-poster. With it, your page makes zero requests to the provider until the visitor actually presses play — which is a page-weight win and a GDPR one.

CORS

WebGL may only sample images the page is allowed to read, so every image and video is requested with crossorigin="anonymous". Same-origin files need nothing. Cross-origin files (CDN, object storage) must send Access-Control-Allow-Origin — without it the browser blocks the texture upload, the LQIP stays up, and you get one console warning. If your CDN cannot send the header, serve slider media from the page's own origin; everything else works identically.

The lightbox

lightbox.enabled (default false) gates only the built-in click trigger. openLightbox() always works regardless:

slider.openLightbox();        // current slide
slider.openLightbox(3);       // a specific index
slider.closeLightbox();

slider.on('lightboxopen',  () => document.body.dataset.modal = 'open');
slider.on('lightboxclose', () => delete document.body.dataset.modal);

With lightbox.deRefract (default true) the open animation irons the glass distortion flat before the image expands; it is skipped under reduced motion and in CSS fallback mode. The lightbox is a role="dialog" / aria-modal element appended to document.body — style it from a global stylesheet, not a scoped one. Focus moves into it on open and returns to the invoking element on close, Tab is trapped inside it, and Esc closes it. Opening a self-hosted video slide migrates the real <video> element into the lightbox with native controls — and because a user gesture opened it, sound is allowed there.

Texture memory — auto-managed

You do not manage GPU memory. The engine does, against a flat 96 MiB budget shared by every instance on the page:

  • Full-resolution textures are held for the current slide ± media.preload neighbours. Slides beyond that window become eviction candidates.
  • While the budget is exceeded, the least-recently-rendered candidate is evicted — on idle frames only, never mid-gesture. 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 neighbours are never evicted.
  • Each texture costs roughly width × height × 4 × 1.34 bytes — the 1.34 covers the mip chain. A 1600×1000 image is about 8.6 MB of GPU memory.
  • The budget and the LRU list are global to the shared context, so ten product vitrines on one page cannot stampede the GPU any harder than a single hero.

Follow the 1600 px guidance and roughly ten slides stay fully resident at once. Beyond that the engine rotates textures and, on a working network, you will not notice.

09 Theming

Two custom properties retheme every control on the slider. Everything beyond that is a stable class name you can style normally.

All shipped chrome — text, arrows, dots, counter, editorial menu, progress rule, focus rings, scrims, the lightbox backdrop — derives from exactly two custom properties. Set them and you have rethemed everything. There is no third token to learn.

<style>
  /* the container gets .rf-root at init; set the tokens there or on any ancestor */
  #hero {
    --rf-ink:   #111111;  /* text, icons, active states, fills */
    --rf-paper: #fafafa;  /* scrims, control backdrops, lightbox backdrop */
    height: 100vh;
  }
</style>

Those two values are also the built-in defaults, declared on .rf-root in refrakt.css. Everything else is derived: control backdrops and the .rf-scrim utility are paper at 72% (via color-mix, falling back to solid paper where unsupported), muted chrome is ink at reduced opacity, and the focus ring is a 2 px ink outline with a 2 px offset.

The two documented pairings

<style>
  /* light chrome — client-safe, boardroom, atelier, frost */
  #hero-light { --rf-ink: #111111; --rf-paper: #fafafa; }

  /* dark chrome — prism-noir, darkroom, eclipse, spectra */
  #hero-dark  { --rf-ink: #f5f2ea; --rf-paper: #0c0c0e; }
</style>

Presets carry no colours. Theming is deliberately not preset data, so one preset works on a light page and a dark page alike. Where a preset has an intended chrome, it is stated in the preset's description and set by your stylesheet.

Styleable parts

Every piece of chrome is plain DOM built from real <button> elements with a stable class name. Override freely — but see Accessibility on contrast and hit targets before you shrink anything.

ClassWhat it is
.rf-rootYour container. Added at init, removed on destroy() unless you already had it. Set the two tokens here.
.rf-slidesThe absolutely-stacked wrapper the engine inserts around your slides at ingestion. Removed on destroy.
.rf-slideYour slides. The engine positions them absolutely inside .rf-slides.
.rf-canvasThe single shared WebGL canvas, appended to <body> as a fixed transparent overlay at z-index: 0. Presentation-only and aria-hidden — do not style it.
.rf-text-layer / .rf-textThe synced caption layer and your caption nodes inside it. Type styling is entirely yours; the engine writes only transform, opacity and visibility.
.rf-navWrapper for all nav chrome. Non-interactive itself; the individual controls take pointer events.
.rf-arrow, .rf-arrow-prev, .rf-arrow-nextArrow buttons — inline SVG chevrons, 44×44 px hit target. Get disabled + aria-disabled at the ends when layout.loop is off.
.rf-spectrum / .rf-spectrum-dotThe dot bar and one button per slide. The active dot carries aria-current="true" and condenses from the dispersed rainbow to solid ink.
.rf-counter, .rf-counter-now, .rf-counter-sep, .rf-counter-totalFraction counter (03 / 08), zero-padded, tabular numerals, aria-hidden.
.rf-menu, .rf-menu-item, .rf-menu-label, .rf-menu-blendEditorial menu — giant numbered buttons, label taken from each slide's first heading. .rf-menu-blend is added when nav.editorialMenu is 'blend' (mounts with mix-blend-mode: difference).
.rf-progress-rule / .rf-progress-rule-fillContinuous progress rule — hairline track plus an ink fill driven by scaleX() (transform only, never width). aria-hidden.
.rf-pauseAutoplay pause/play toggle. Mounted automatically whenever autoplay is enabled and not removable via nav options.
.rf-play-badgeThe circular play affordance on YouTube/Vimeo poster slides.
.rf-announcerThe visually-hidden aria-live="polite" region. Do not un-hide it.
.rf-lightbox, .rf-lightbox-backdrop, .rf-lightbox-content, .rf-lightbox-closeThe lightbox, appended to document.body — style it from a global sheet. .rf-scroll-lock is applied to the document element while it is open.
.rf-scrimUtility class you apply yourself: paper-at-72% backing for text over imagery. Passes 4.5:1 with either documented ink pair.
.rf-rgb-echoStamped on h1h3 inside .rf-text when text.rgbEcho is on. The echo width is driven by the --rf-echo custom property, written on the container each frame.
.rf-draggingOn the root for the duration of a pointer drag (grabbing cursor, selection suppressed).
.rf-css-mode / .rf-fallbackBoth added to the container in CSS fallback mode. .rf-fallback is the hook to style for.

Two things are intentionally not themeable: the spectrum bar's inactive rainbow gradient, and the red/blue constants of the RGB echo. They are the product's spectral identity. Only the active states take your ink.

10 Accessibility

What the engine handles on your behalf, and the handful of things that remain your responsibility.

What the engine does for you

ARIA carousel pattern

Applied at init, restored attribute-for-attribute by destroy(), following the WAI-ARIA APG carousel pattern.

  • Container: role="region", aria-roledescription="carousel", aria-label from a11y.label (default 'Image carousel'), and tabindex="0" if it did not already have one. If you supplied your own aria-label or aria-labelledby, it is preserved, never overwritten — including by later setOption('a11y.label', …) calls.
  • Each slide: role="group", aria-roledescription="slide", aria-label="3 of 8". Every non-current slide gets aria-hidden="true" and its focusable descendants get tabindex="-1" (originals recorded and restored when the slide becomes current). A virtual cursor therefore sees exactly one slide's content plus the controls, not a wall of eight captions.
  • Focus safety: if the committed slide changes while focus sits inside a slide that just became hidden, focus moves to the container root — never to a hidden element.
  • What screen readers actually read is your original DOM — real alt text and real caption content. The canvas is aria-hidden presentation.

Live region

A visually-hidden aria-live="polite", aria-atomic announcer reports "Slide 3 of 8: Title" on change — the title being the slide's first heading (h1h6), empty if there is none. Writes are debounced 150 ms, so a rapid multi-slide drag announces only the landing slide. Announcements are suppressed while a change came from autoplay (a polite region firing every six seconds is noise) and resume the moment the user navigates. a11y.announceSlides: false disables the live region only — the structural ARIA always ships.

Keyboard

KeyContextAction
Left / Right (Up / Down when layout.axis: 'y')focus anywhere inside the containerprev() / next(). preventDefault only on the handled axis, so page scroll on the other axis still works.
Home / Endinside the containergoTo(0) / goTo(total − 1)
Enter / Spaceon any nav buttonnative button activation
Esclightbox opencloseLightbox()
Tablightbox openfocus trapped inside .rf-lightbox

Keys are ignored while a drag is in flight, when the event was already preventDefaulted, and whenever focus is in an <input>, <textarea>, <select> or a contenteditable region — so a search box inside a slide still works. motion.keyboard: false turns the map off. Navigating via the dots or the editorial menu does not steal focus; the live region announces instead.

Reduced motion

The engine reads prefers-reduced-motion at init and subscribes to changes — flipping the OS setting takes effect on the next transition, with no reload. Reduced-motion users stay on the WebGL path; they do not get the fallback. What changes: transitions become an opacity dissolve, velocity-driven effects (shear, dispersion tear, RGB echo, text parallax) pin to zero, and the frost-up reveal shortens to a 200 ms linear fade. The effective state is mirrored onto the container as data-rf-reduced, which the stylesheet uses to kill transitions and animations. a11y.reducedMotion overrides it: 'auto' follows the OS, 'always' forces reduced, 'never' forces full motion.

Autoplay pause control (WCAG 2.2.2)

Whenever autoplay is enabled a visible pause button is mounted, and nav options cannot remove it. This is hard-coded on purpose. It is a real <button> with aria-pressed and a label that swaps between "Pause slideshow" and "Play slideshow". Pausing is sticky: no timer, hover-out or slide change restarts it — only the user, or an explicit play() call. motion.autoplay.pauseOnHover additionally suspends the timer on hover and on focus. All interactive chrome meets a 44×44 px minimum hit target.

Fixed UI strings

a11y.label is the only configurable UI string in v1. Everything else — "Previous slide", "Next slide", "Go to slide {i} of {n}", "Pause slideshow" / "Play slideshow", "Close fullscreen view", "Media viewer", the "{i} of {n}" slide labels and the "Slide {i} of {n}: {title}" announcement — is a fixed English constant.

What you still have to do

  • Alt text on every image. The engine reads img.alt and carries it through to the lightbox, but it does not validate it and will not warn you. A missing alt is a silent failure. Mark genuinely decorative images alt="" explicitly.
  • Contrast. If you set your own ink/paper pair, keep it at 4.5:1 or better for text and 3:1 for non-text chrome. Both documented pairs sit far above that.
  • Text over imagery is your risk. A caption's legibility depends on the photograph underneath it, which the engine cannot know. Back it with the shipped .rf-scrim utility.
  • A meaningful label. Set a11y.label (or your own aria-label) to something better than the default — "Spring 2026 lookbook", not "Image carousel".
  • Captions for video. Any <track> you author passes through untouched. The engine never synthesizes one.

a11y.reducedMotion: 'never' forces full motion on visitors who explicitly asked their operating system for less. That is an author decision and an accessibility liability you are taking on. Leave it at 'auto' unless you have a documented reason not to.

11 Performance & fallback

How the engine protects its frame rate on weak hardware, and what a visitor without WebGL2 gets instead.

The auto quality governor

One governor runs per shared GL context, aggregating the quality config of every instance on the page (strictest fpsFloor wins, largest maxDPR wins).

OptionTypeDefaultRange
quality.tierenum'auto''auto', 'high', 'lite'
quality.maxDPRnumber21 – 3
quality.fpsFloorinteger4824 – 58

It measures the p95 of the last 60 animating frames — deltas between consecutive animating rAF timestamps only, so idle and breathing frames never pollute the sample. If p95 exceeds 1000 / fpsFloor for three consecutive windows, the governor drops one step down a five-step ladder:

  1. Steps 1–3 tighten the device-pixel-ratio cap progressively (1.5, then 1.25, then 1.0), below whatever quality.maxDPR already allows. Fewer pixels, identical shader.
  2. Step 4 additionally switches every instance whose quality.tier is 'auto' to the lite shader tier — the cheaper optics path.

Recovery is deliberately slow and asymmetric: after 30 seconds of clean windows the governor climbs back exactly one step, and the upgrade timer resets on any borderline window (there is a hysteresis band at 80% of the threshold) and on every visibilitychange. It never yo-yos, and it never promotes in the middle of a bad patch.

Pinning the tier opts out of the shader half of the ladder: with quality.tier: 'high' or 'lite' the governor still manages DPR (steps 1–3) but never touches your shader choice. An all-lite page also caps DPR at 1.5 from the start.

Render on demand

There is exactly one requestAnimationFrame loop for the whole page, owned by the renderer, and every instance is scissored into the same shared canvas — one WebGL draw call per instance per frame, and no instance ever schedules its own frame.

The loop parks itself the moment nothing needs drawing: no instance is dragging, seeking, running a timeline or playing video, and no visible instance is breathing or drifting. A settled slider that nobody is touching costs zero frames — not a cheap frame, no frame. Any dirty source (an option write, a resize, a texture swap, a pointer event) wakes it again. document.hidden parks everything unconditionally and pauses video decoding with it. Sliders scrolled out of the viewport keep their integrators ticking but stop being drawn, and their last pixels are erased from the shared canvas so nothing bleeds through.

Memory

A flat 96 MiB texture budget, shared by every instance on the page. Full-resolution textures outside the current slide ± media.preload window are eviction candidates; while the budget is exceeded the least-recently-rendered candidate is dropped back to its LQIP, on idle frames only. The current slide and its immediate neighbours are never evicted. Textures are capped at 2048 px regardless of DPR. See Media for the full accounting.

The CSS fallback mode

When WebGL2 is unavailable, Refrakt mounts a complete non-GL implementation of the same instance: same markup, same options, same methods, same events, same gesture physics (pointer, wheel and keyboard feed the identical integrator). Transitions become crossfades, and lens-mode side slides get a backdrop-filter glass twin instead of refraction. Buyer code never branches.

It engages for exactly three reasons, each announced by a fallback event carrying { reason }:

reasonWhen
'no-webgl2'A cheap probe at construction found no WebGL2 context — old browser, blocklisted GPU driver, hardware acceleration disabled, enterprise policy. The probe result is cached for the page lifetime.
'forced'forceFallback: true was set. This is how you test the fallback, and how you opt a page out of GL deliberately.
'context-lost'The GPU context was lost at runtime and did not come back. A single loss is survivable — the engine opts into restoration, rebuilds the program, re-uploads every texture from HTTP cache and carries on. It takes two losses with no restore inside 3 seconds to declare the environment hostile, at which point every live instance migrates to CSS mode, keeping its current index, one-way for the rest of the page's life.
const slider = new Refrakt('#hero');

slider.on('fallback', ({ reason }) => {
  // 'no-webgl2' | 'forced' | 'context-lost'
  console.log('Refrakt is in CSS mode:', reason);
});

// 'fallback' fires BEFORE 'ready' in the no-webgl2 and forced cases.
slider.on('ready', ({ mode }) => {
  document.body.dataset.rfMode = mode; // 'gl' | 'css'
});

In CSS mode the container carries both .rf-css-mode and .rf-fallback; style the latter. GL-only options (glass.*, dispersion.*, light.*) are still accepted, validated and stored — reading them back returns what you set — they simply have nothing to drive. And because CSS mode renders entirely inside the container rather than on a page-level fixed canvas, forceFallback: true is also the escape hatch for pages whose layout puts a transform or filter on an ancestor of the slider (see Troubleshooting).

prefers-reduced-motion users do not get the fallback. They stay on the GL engine with motion reduced per a11y.reducedMotion.

12 Recipes

Copy-pasteable answers to the things buyers actually ask for. Real methods, real option paths, real event payloads — nothing here is pseudo-code.

Copy-pasteable answers to the things buyers actually ask for. Every snippet below uses only real methods, real option paths and real event payloads — nothing is pseudo-code. All recipes assume you have an instance:

// UMD (window.Refrakt) or: import Refrakt from 'refrakt';
const slider = new Refrakt('#hero');            // selector or HTMLElement

Mutating methods chain (next, prev, goTo, setOption, play, pause, openLightbox, closeLightbox, on, off, rebuild); the readers (getIndex, getProgress, getOption) return values, and destroy() returns nothing.

Register a custom preset

Refrakt.registerPreset() is a static and must run before the instance that uses it is constructed. It accepts a full preset file ({ meta, options }) or a bare options object.

Refrakt.registerPreset('house-style', {
  meta: { name: 'house-style', version: '1.0.0', description: 'Brand slider' },
  options: {
    glass: { kind: 'reeded', ior: 0.12,
             reeded: { frequency: 18, depth: 0.6, sheen: 0.5 } },
    dispersion: { amount: 0.2, velocityGain: 0.45, maxTear: 0.3 },
    nav: { spectrum: false, counter: true }
  }
});

// Bare options work too:
Refrakt.registerPreset('flat', { glass: { kind: 'none' } });

// Use it — explicitly…
const slider = new Refrakt('#hero', { preset: 'house-style' });
// …or from markup: <div data-refrakt="house-style">

Refrakt.presets['house-style'].meta.description; // frozen registry, read-only

Options are validated at registration: out-of-range numbers are clamped, unknown keys are dropped, and re-registering a name overwrites it with a console warning. The merge order is factory defaults ← preset ← data-rf-options ← constructor options.

With the UMD build, Refrakt.auto() runs on DOMContentLoaded, so an inline <script> that registers presets right after the library tag still lands before any [data-refrakt] element is instantiated.

Wire external prev / next buttons

// Turn the built-in arrows off if you're supplying your own chrome.
const slider = new Refrakt('#hero', { nav: { arrows: false } });

document.querySelector('#prev').addEventListener('click', () => slider.prev());
document.querySelector('#next').addEventListener('click', () => slider.next());

// Jump straight to a slide; { instant: true } skips the animation.
document.querySelector('#last').addEventListener('click', () => slider.goTo(4));

next() and prev() wrap when layout.loop is true (the default) and are animated. Both chain, so slider.prev().prev() steps back two.

Sync custom pagination to the change event

ready hands you slideCount; change fires exactly once per committed index change, whatever caused it.

const slider = new Refrakt('#hero', { nav: { spectrum: false } });
const bar = document.querySelector('#dots');
let dots = [];

slider.on('ready', (e) => {
  // e.slideCount, e.mode ('gl' | 'css'), e.instance, e.index
  bar.textContent = '';
  dots = Array.from({ length: e.slideCount }, (_, i) => {
    const b = document.createElement('button');
    b.type = 'button';
    b.setAttribute('aria-label', 'Go to slide ' + (i + 1));
    b.addEventListener('click', () => slider.goTo(i));
    bar.appendChild(b);
    return b;
  });
  paint(e.index);
});

slider.on('change', (e) => {
  // e.index, e.previousIndex, e.source, e.instance
  // source: 'drag' | 'api' | 'autoplay' | 'keyboard' | 'wheel' | 'hash'
  paint(e.index);
});

function paint(i) {
  dots.forEach((b, n) =>
    b.setAttribute('aria-current', n === i ? 'true' : 'false'));
}

Every payload also carries instance and index. Subscribing to ready after it has already fired replays it immediately, so load order never costs you the event.

Autoplay with a pause control

const slider = new Refrakt('#hero', {
  motion: { autoplay: { enabled: true, interval: 6000, pauseOnHover: true } }
});

const btn = document.querySelector('#autoplay-toggle');
btn.addEventListener('click', () => {
  const on = slider.getOption('motion.autoplay.enabled');
  if (on) slider.pause(); else slider.play();
  btn.setAttribute('aria-pressed', String(!on));
  btn.textContent = on ? 'Play' : 'Pause';
});

play() and pause() write motion.autoplay.enabled, so getOption() always reflects the true state of your button. interval is clamped to 1500–60000 ms.

Autoplay also suspends itself — without touching the option — while the pointer hovers (pauseOnHover), while the lightbox is open, while the tab is hidden, and whenever the slider is under 35% on screen. The Space bar toggles a sticky pause while the slider has focus.

Deep-link slides with the hash option

<!-- The container MUST have an id — the hash is built from it. -->
<div id="gallery" data-refrakt="atelier" data-rf-options='{ "hash": true }'>
  <figure class="rf-slide">…</figure>
</div>
const slider = new Refrakt('#gallery', { hash: true });

slider.on('change', (e) => {
  if (e.source === 'hash') console.log('arrived via back/forward');
});
  • The hash is #<container-id>-<n> and 1-based: #gallery-3 is the third slide.
  • No id on the container means no hash — the option silently does nothing.
  • On load the hash wins over layout.startIndex.
  • Each settled transition pushes a history entry, so Back returns to the previous slide; commits mid-flight only replace it.
  • While the lightbox is open the hash gains a /view suffix (#gallery-3/view), and Back closes the lightbox.

Opt a slide into the lightbox

lightbox.enabled turns the click-to-open trigger on for the whole instance. What a slide shows in the lightbox comes from its media attributes.

<div data-refrakt="atelier" data-rf-options='{ "lightbox": { "enabled": true } }'>

  <!-- data-rf-src is the full-size source the lightbox opens -->
  <figure class="rf-slide">
    <img src="img/01-lqip.jpg" data-rf-src="img/01-full.jpg" alt="Look 01">
  </figure>

  <!-- self-hosted video: opens with native controls, unmuted -->
  <figure class="rf-slide" data-rf-video="video/reel.mp4"
          data-rf-poster="img/reel-poster.jpg">
    <img src="img/reel-lqip.jpg" alt="Showreel">
  </figure>

  <!-- data-rf-youtube / data-rf-vimeo force THIS slide's lightbox on,
       even when lightbox.enabled is false -->
  <figure class="rf-slide" data-rf-youtube="dQw4w9WgXcQ"
          data-rf-poster="img/talk-poster.jpg">
    <img src="img/talk-lqip.jpg" alt="Keynote">
  </figure>
</div>
slider.openLightbox();     // current slide
slider.openLightbox(2);    // by index (wrapped when looping, else clamped)
slider.closeLightbox();    // idempotent

slider.on('lightboxopen',  () => document.body.classList.add('is-viewing'));
slider.on('lightboxclose', () => document.body.classList.remove('is-viewing'));

A slide with an <img> always has something to show — data-rf-src if present, otherwise the image's own src. A slide with neither an image nor video/poster/YouTube/Vimeo attributes refuses to open and warns to the console. Links and buttons inside .rf-text never trigger the lightbox, so captions stay clickable.

Switch optics live with setOption()

// One dot-path…
slider.setOption('glass.kind', 'wedge');

// …or a batch, deep-merged in a single call:
slider.setOption({
  glass: { kind: 'wedge', ior: 0.2,
           wedge: { angle: 22, width: 0.24, gain: 1.6 } },
  dispersion: { amount: 0.85, velocityGain: 1.4, maxTear: 1.0 },
  exposure: { bloom: 0.3, grain: 0.12 },
  text: { rgbEcho: true }
});

slider.getOption('glass.ior');   // 0.2 — the resolved, post-clamp value

Everything under glass, dispersion, light, exposure, motion, text, nav, lightbox, a11y, quality and hash applies on the next frame. Out-of-range numbers are clamped (glass.ior 0–0.35, dispersion.maxTear 0–1) with a one-time console warning — a bad value gives you a different look, never a broken slider.

A handful of keys are stored but need a rebuild: preset, layout.mode, layout.startIndex, every media.* key, forceFallback and debug.

slider.setOption('layout.mode', 'lens').rebuild(); // keeps index + handlers

Frosted condensation wipe (pointer clears the glass)

The clear-patch interaction needs three things together — a hero frost pane that is permanently on (glass.frost.mode: 'rest'), a pointer-driven light, and a non-zero clearRadius. On the default 'transition' mode the pane is a veil that is fully clear at rest, so clearRadius has nothing to wipe.

const slider = new Refrakt('#testimonials', {
  glass: { kind: 'frost', frost: { mode: 'rest', coverage: 0.95, scale: 220 } },
  light: { followPointer: 0.5, clearRadius: 0.65 }
});

All three are required. light.clearRadius on its own — or with glass.frost.mode left at 'transition', or in layout.mode: 'lens' — produces no visible wipe.

Force the CSS fallback (for testing)

The GL-vs-CSS decision is made once, at construction. Pass forceFallback to the constructor, or put it in data-rf-options.

const slider = new Refrakt('#hero', { forceFallback: true });

slider.on('fallback', (e) => console.log(e.reason)); // 'forced'
slider.on('ready',    (e) => console.log(e.mode));   // 'css'
<div data-refrakt="atelier" data-rf-options='{ "forceFallback": true }'>

setOption('forceFallback', true).rebuild() does not switch an existing instance to CSS mode. rebuild() re-initializes the implementation the instance already has; only the constructor chooses between GL and CSS. To flip a live instance, tear it down and build a new one:

slider.destroy();                                  // restores the original DOM
const cssSlider = new Refrakt('#hero', { forceFallback: true });

The same fallback event fires on its own with reason: 'no-webgl2' when the browser has no WebGL2, and reason: 'context-lost' after an unrecovered GPU context loss. In every case the public API is identical — the same methods, the same events, the same option paths.

13 Troubleshooting

Every console warning the engine can emit, what causes it, and what to do about it.

Refrakt is engineered to degrade rather than break: bad options clamp, missing media falls back, a machine without WebGL2 gets a CSS slider with the identical API. When something still looks wrong it is almost always one of the rows below — and almost always already announced by a [Refrakt] line in the browser console. Open the console first. Every warning fires at most once per cause and never accompanies an exception.

SymptomCauseFix
Nothing renders. Console: container has no .rf-slide children; instance is inert Slides are missing, use a different class, or are not direct children — a wrapper <div> between the container and the slides breaks detection, because the scan is :scope > .rf-slide. Make every slide an immediate child of the data-refrakt element, with the class rf-slide.
Nothing renders. Console: element not found for "…"; instance is inert The selector matched nothing — a typo, or the script ran before the element existed. Fix the selector, or construct after DOMContentLoaded. The UMD auto-init already waits for it.
Raw stacked images, unstyled arrows, and no warnings at all. refrakt.css is not linked. The JS bundle deliberately injects no CSS, so there is nothing to warn about. Add the stylesheet <link> in <head>.
The container collapses to zero height. Slides are absolutely positioned at init, so the container has no intrinsic height left. Give the container an explicit size in your CSS: height: 100vh, a fixed height, or aspect-ratio.
The slider area shows the page background instead of the slides. Theme CSS paints an opaque background over the media area. The shared canvas is a fixed transparent overlay at z-index: 0, so any opaque layer above it hides the rendering. Remove opaque background from the container, its slides, and any wrapper covering the slider's box.
Rendered slides drift out of alignment with their container, or scroll away from it. Known CSS limitation. An ancestor with transform, filter, backdrop-filter, perspective or will-change: transform becomes the containing block for position: fixed descendants, so the shared canvas stops being viewport-fixed. Typically a smooth-scroll library translating a page wrapper, or a builder's page-transition effect. Remove the transform/filter from the ancestor (prefer opacity-based transitions, which create no containing block). If that is non-negotiable, set "forceFallback": true — CSS mode renders entirely inside the container, with the same API and events.
Content elsewhere on the page is hidden behind the slider's pixels. A theme element with z-index: -1 (or auto-stacking below the canvas) sits under the transparent overlay. Give page content a normal stacking position (≥ 0). Slide text sits at z-index: 10, nav at 20.
A slide stays a frosted blur. Console: slide N media failed: <url> The data-rf-src path 404s. Paths resolve relative to the page URL, not to the script or the stylesheet. Fix the path — the failing URL is printed in the warning.
Console: slide N decode timeout: <url> Fetch + decode exceeded the fixed 8-second budget — usually a multi-megabyte original on a slow connection. Export at 1600 px on the longest edge. The LQIP stays up meanwhile, so the page is never blank.
CDN-hosted images never sharpen past the LQIP. Missing CORS header. Every image is requested with crossorigin="anonymous", and WebGL cannot sample a cross-origin image the page is not allowed to read. Send Access-Control-Allow-Origin from the CDN, or serve slider media from the page's own origin.
data-rf-srcset is ignored, or always picks the wrong size. Density descriptors (2x) do not parse — width descriptors (1600w) only. Or the window was resized after load: candidate selection is one-shot by design, to prevent download storms on drag-resize. Use width descriptors, authored around your real container widths.
The image is sharp but low-res on hi-DPI screens, and there are no warnings. Only the inline src was provided, so the LQIP is being used as both placeholder and final image. Add data-rf-src with the full-resolution file.
A slide renders as a flat ink-coloured panel. Console: slide N has no media The slide has no <img>, no data-rf-src, no data-rf-video and no provider attribute — or both the LQIP and the full file failed. The ink panel is the terminal fallback. Fix the media. The slide is deliberately kept in the sequence so indices, deep links and the counter stay stable.
A video's poster shows but playback never starts. Browser autoplay policy blocked the muted play() — rare; usually data-saver mode or an embedded webview. Nothing. Playback retries on the visitor's next interaction. Never set media.video.muted: false expecting autoplay with sound: no browser permits it, and the engine forces mute while autoplaying regardless.
Video plays, then pauses "randomly". By design: a video plays only while its slide is the committed centre slide and the tab is visible. Leaving centre pauses it — it resumes, it does not restart. None. This is what keeps a video-heavy page off the battery.
Video works in Safari, dead in Chrome/Firefox. Console: slide N video failed: <url> An HLS (.m3u8) source. Natively supported only by Safari/iOS, and no hls.js is bundled — the zero-dependency rule. Provide an H.264 MP4. It is the one universally decodable format.
Some video slides show stills on a video-heavy page. The shared decoder pool holds at most 4 <video> elements (mobile Safari's cap). The furthest-from-centre slides release theirs and fall back to their poster. None — but always supply data-rf-poster so the still is intentional rather than an ink panel.
A YouTube/Vimeo slide never plays "inline". Not a bug. A cross-origin iframe cannot be a WebGL texture. Provider slides show a refractable poster; playback opens in the lightbox, which is forced on for those slides. That is the designed behaviour — the poster pattern.
Console: Vimeo oEmbed failed for <id>; supply data-rf-poster Poster derivation could not reach Vimeo — offline, a private video, or a provider change. Add data-rf-poster. It is effectively required for Vimeo slides, and it also removes every runtime request to the provider.
Console: data-rf-options ignored: <parse error> The attribute is not valid JSON — almost always single-quoted keys or a trailing comma. Wrap the attribute in single quotes and use double quotes inside. The preset still applied; only your overrides were dropped.
Console: unknown option "<path>" ignored or <path> <v> clamped to <limit> A typo, wrong nesting, or a value outside its documented range. The slider runs on the clamped/default value. Check the path and range against the options reference. The slider is working — it just is not doing what you asked.
Console: "<path>" requires rebuild(); stored, not applied setOption() was called on a non-live key (layout.mode, layout.startIndex, media.*, preset, forceFallback, debug). The value is stored, not applied — it never half-applies. Call rebuild() to take it live. Handlers and the current index survive.
Console: element already bound; returning the existing instance new Refrakt() ran twice on one element — usually UMD auto-init plus a manual construction. Harmless: you were handed the original instance, not a second one. Drop the data-refrakt attribute if you want to construct manually.
Console: shader program failed: <log>, and the slider falls back to CSS. The GPU driver rejected the shader. Extremely rare and effectively always a broken driver. Nothing at the page level — the instance already degraded safely. If you see this on current hardware, send the console log.

Strict CSP

Refrakt is CSP-friendly by construction: no eval, no Web Workers, no runtime style injection, no inline scripts, no external fetches for its own code. What your policy must allow depends only on where your media lives — img-src for slide images, media-src for self-hosted MP4s, frame-src for www.youtube-nocookie.com / player.vimeo.com if you use provider slides, and connect-src vimeo.com only if you rely on Vimeo poster derivation instead of supplying data-rf-poster. A fully self-hosted page — own images, own MP4s, data-rf-poster on provider slides — runs under default-src 'self' with no exceptions at all.