Copied!
Full page

Floating player controls

A glass video control bar you can drag anywhere over the picture, which fades away while playing and comes back when you move.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Floating player controls" for my site using the Enrichr Library element at https://enrichr.dev/elements/floating-player-controls.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/floating-player-controls.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- photo: corgi-4
- length: 90
- autoplay: on
- hide: 2.5
- pan: 1
- accent: #e6ff55
- button: #fbf8f1
- ink: #0c1e18
- text: #fbf8f1
- glass: #050d0a

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/floating-player-controls.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-fpc">
  <div class="ef-fpc__screen"><img class="ef-fpc__img" src="/img/library/corgi-4.jpg" alt="A corgi and a shaggy dog on a dirt road, standing in for the video" decoding="async" draggable="false"></div>
  <div class="ef-fpc__bar" role="group" aria-label="Video controls">
    <div class="ef-fpc__top">
      <span class="ef-fpc__time ef-fpc__cur" aria-hidden="true">0:00</span>
      <input type="range" class="ef-fpc__range ef-fpc__timeline" min="0" max="90" step="0.1" value="0" aria-label="Seek">
      <span class="ef-fpc__time ef-fpc__dur" aria-hidden="true">1:30</span>
    </div>
    <div class="ef-fpc__row">
      <button type="button" class="ef-fpc__handle" aria-label="Move the controls: drag, or use the arrow keys. Home puts them back." title="Drag to move"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><g fill="currentColor"><circle cx="8.8" cy="5.6" r="1.35"/><circle cx="15.2" cy="5.6" r="1.35"/><circle cx="8.8" cy="12" r="1.35"/><circle cx="15.2" cy="12" r="1.35"/><circle cx="8.8" cy="18.4" r="1.35"/><circle cx="15.2" cy="18.4" r="1.35"/></g></svg></button>
      <div class="ef-fpc__mid">
        <button type="button" class="ef-fpc__btn ef-fpc__back" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><g fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3.5 8.5"/><path d="M3.5 3.8v4.7h4.7"/><path d="M12 7.6V12l3 1.8"/></g></svg></button>
        <button type="button" class="ef-fpc__play" aria-label="Play"><svg class="ef-fpc__i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M7.5 4.9v14.2c0 .9 1 1.4 1.7 1l11.3-7.1c.7-.4.7-1.5 0-1.9L9.2 3.9c-.7-.5-1.7 0-1.7 1z"/></svg><svg class="ef-fpc__i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><g fill="currentColor"><rect x="5.8" y="4" width="4.6" height="16" rx="1.3"/><rect x="13.6" y="4" width="4.6" height="16" rx="1.3"/></g></svg></button>
        <button type="button" class="ef-fpc__btn ef-fpc__fwd" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><g fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" transform="translate(24 0) scale(-1 1)"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3.5 8.5"/><path d="M3.5 3.8v4.7h4.7"/><path d="M12 7.6V12l3 1.8"/></g></svg></button>
      </div>
      <div class="ef-fpc__vol">
        <button type="button" class="ef-fpc__mute" aria-label="Mute" data-level="high"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3.6 9h3.3l4.6-4c.6-.5 1.5-.1 1.5.7v12.6c0 .8-.9 1.2-1.5.7l-4.6-4H3.6c-.6 0-1.1-.5-1.1-1.1V10.1c0-.6.5-1.1 1.1-1.1z"/><g fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round"><path class="ef-fpc__w1" d="M16 9.2a4 4 0 0 1 0 5.6"/><path class="ef-fpc__w2" d="M18.6 6.6a7.6 7.6 0 0 1 0 10.8"/><path class="ef-fpc__x" d="M16.2 9.4l5 5.2M21.2 9.4l-5 5.2"/></g></svg></button>
        <div class="ef-fpc__volwrap"><input type="range" class="ef-fpc__range ef-fpc__volume" min="0" max="100" step="1" value="100" aria-label="Volume"></div>
      </div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-fpc { --ef-fpc-acc: #e6ff55; --ef-fpc-btn: #fbf8f1; --ef-fpc-ink: #0c1e18; --ef-fpc-text: #fbf8f1; --ef-fpc-glass: #050d0a; --ef-fpc-mute: #e54c1e; position: relative; width: 100%; min-height: 26rem; box-sizing: border-box; overflow: hidden; border-radius: 1rem; background: #050d0a; color: var(--ef-fpc-text); font-family: 'Instrument Sans', system-ui, sans-serif; container-type: inline-size; }
.ef-fpc.is-idle { cursor: none; }
.ef-fpc .ef-fpc__screen { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.ef-fpc .ef-fpc__img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; transform-origin: 50% 50%; will-change: transform; user-select: none; }
.ef-fpc .ef-fpc__bar { position: absolute; left: 0; right: 0; bottom: 3rem; z-index: 1; box-sizing: border-box; width: min(420px, calc(100% - 1.5rem)); margin: 0 auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; border: 1px solid color-mix(in srgb, var(--ef-fpc-text) 10%, transparent); border-radius: 1.5rem; background: color-mix(in srgb, var(--ef-fpc-glass) 40%, transparent); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 0, 0, 0.2); opacity: 0; cursor: default; will-change: transform, opacity; }
.ef-fpc .ef-fpc__top { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.5rem; }
.ef-fpc .ef-fpc__time { flex: none; width: 2.5rem; font-family: 'Fira Code', ui-monospace, monospace; font-size: 0.75rem; line-height: 1rem; color: color-mix(in srgb, var(--ef-fpc-text) 82%, transparent); font-variant-numeric: tabular-nums; }
.ef-fpc .ef-fpc__cur { text-align: right; }
.ef-fpc .ef-fpc__dur { text-align: left; }
.ef-fpc .ef-fpc__row { display: flex; align-items: center; justify-content: space-between; }
.ef-fpc .ef-fpc__mid { display: flex; align-items: center; gap: 1rem; }
.ef-fpc .ef-fpc__vol { display: flex; align-items: center; gap: 0.5rem; padding-left: 1rem; }
.ef-fpc .ef-fpc__volwrap { width: 4rem; overflow: hidden; display: flex; align-items: center; }
.ef-fpc .ef-fpc__handle, .ef-fpc .ef-fpc__btn, .ef-fpc .ef-fpc__mute, .ef-fpc .ef-fpc__play { margin: 0; border: 0; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.ef-fpc .ef-fpc__handle { display: flex; padding: 0.5rem; border-radius: 0.6rem; background: transparent; color: color-mix(in srgb, var(--ef-fpc-text) 62%, transparent); cursor: grab; touch-action: none; transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.ef-fpc .ef-fpc__handle:hover, .ef-fpc .ef-fpc__handle.is-drag { color: var(--ef-fpc-text); }
.ef-fpc .ef-fpc__handle.is-drag { cursor: grabbing; }
.ef-fpc .ef-fpc__btn { display: flex; align-items: center; justify-content: center; padding: 0.5rem; border-radius: 9999px; background: transparent; color: color-mix(in srgb, var(--ef-fpc-text) 82%, transparent); transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.ef-fpc .ef-fpc__btn:hover { color: var(--ef-fpc-text); background: color-mix(in srgb, var(--ef-fpc-text) 10%, transparent); }
.ef-fpc .ef-fpc__play { display: flex; align-items: center; justify-content: center; flex: none; width: 3rem; height: 3rem; padding: 0; border-radius: 9999px; background: var(--ef-fpc-btn); color: var(--ef-fpc-ink); box-shadow: 0 0 15px color-mix(in srgb, var(--ef-fpc-btn) 40%, transparent); transition: scale 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.ef-fpc .ef-fpc__play:hover { scale: 1.05; }
.ef-fpc .ef-fpc__mute { display: flex; padding: 0; background: transparent; color: color-mix(in srgb, var(--ef-fpc-text) 82%, transparent); transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.ef-fpc .ef-fpc__mute:hover { color: var(--ef-fpc-text); }
.ef-fpc .ef-fpc__mute[data-level="off"] { color: var(--ef-fpc-mute); }
.ef-fpc .ef-fpc__bar svg { display: block; flex: none; }
.ef-fpc .ef-fpc__handle svg, .ef-fpc .ef-fpc__btn svg, .ef-fpc .ef-fpc__play svg { width: 24px; height: 24px; }
.ef-fpc .ef-fpc__mute svg { width: 20px; height: 20px; }
.ef-fpc .ef-fpc__play .ef-fpc__i-pause, .ef-fpc .ef-fpc__play.is-playing .ef-fpc__i-play { display: none; }
.ef-fpc .ef-fpc__play.is-playing .ef-fpc__i-pause { display: block; }
.ef-fpc .ef-fpc__mute .ef-fpc__x, .ef-fpc .ef-fpc__mute[data-level="off"] .ef-fpc__w1, .ef-fpc .ef-fpc__mute[data-level="off"] .ef-fpc__w2, .ef-fpc .ef-fpc__mute[data-level="low"] .ef-fpc__w2 { display: none; }
.ef-fpc .ef-fpc__mute[data-level="off"] .ef-fpc__x { display: inline; }
.ef-fpc .ef-fpc__handle:focus-visible, .ef-fpc .ef-fpc__btn:focus-visible, .ef-fpc .ef-fpc__mute:focus-visible, .ef-fpc .ef-fpc__play:focus-visible { outline: 2px solid var(--ef-fpc-acc); outline-offset: 2px; }
.ef-fpc .ef-fpc__range { --ef-fpc-p: 0%; --ef-fpc-fill: var(--ef-fpc-acc); -webkit-appearance: none; appearance: none; display: block; flex: 1 1 auto; min-width: 0; width: 100%; height: 16px; margin: 0; padding: 0; background: transparent; outline: none; cursor: pointer; }
.ef-fpc .ef-fpc__volume { --ef-fpc-fill: var(--ef-fpc-text); }
.ef-fpc .ef-fpc__range::-webkit-slider-runnable-track { height: 4px; border-radius: 4px; background: linear-gradient(to right, var(--ef-fpc-fill) var(--ef-fpc-p), color-mix(in srgb, var(--ef-fpc-text) 20%, transparent) var(--ef-fpc-p)); }
.ef-fpc .ef-fpc__range::-moz-range-track { height: 4px; border-radius: 4px; background: linear-gradient(to right, var(--ef-fpc-fill) var(--ef-fpc-p), color-mix(in srgb, var(--ef-fpc-text) 20%, transparent) var(--ef-fpc-p)); }
.ef-fpc .ef-fpc__range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 0; border-radius: 50%; background: var(--ef-fpc-text); box-shadow: 0 0 10px color-mix(in srgb, var(--ef-fpc-text) 60%, transparent); transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.ef-fpc .ef-fpc__range::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--ef-fpc-text); box-shadow: 0 0 10px color-mix(in srgb, var(--ef-fpc-text) 60%, transparent); transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.ef-fpc .ef-fpc__range::-webkit-slider-thumb:hover { transform: scale(1.3); }
.ef-fpc .ef-fpc__range::-moz-range-thumb:hover { transform: scale(1.3); }
.ef-fpc .ef-fpc__volume::-webkit-slider-thumb { opacity: 0; }
.ef-fpc .ef-fpc__volume::-moz-range-thumb { opacity: 0; }
.ef-fpc .ef-fpc__range:focus-visible::-webkit-slider-thumb { opacity: 1; box-shadow: 0 0 0 3px var(--ef-fpc-acc), 0 0 10px color-mix(in srgb, var(--ef-fpc-text) 60%, transparent); }
.ef-fpc .ef-fpc__range:focus-visible::-moz-range-thumb { opacity: 1; box-shadow: 0 0 0 3px var(--ef-fpc-acc), 0 0 10px color-mix(in srgb, var(--ef-fpc-text) 60%, transparent); }
@container (max-width: 360px) { .ef-fpc .ef-fpc__volwrap { display: none; } .ef-fpc .ef-fpc__vol { padding-left: 0.5rem; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Floating player controls: a glass control bar that floats over a "video" and can be dragged anywhere inside it by its
// grip. There is no real video: a clock plays a 1:30 clip, the scrubber and times follow it, and a corgi photo pans and
// zooms slowly with the playhead, so scrubbing moves the picture too. The motion follows the original's GSAP set-up:
// the bar fades in (0.4 s power3 out), hides after 2.5 s without pointer movement while playing (0.6 s power2 in-out,
// fading and sinking 15 px) and comes back on any movement, tap or key. Dragging scales it to 1.02 (0.2 s back out 1.7),
// lets it travel past the edges at a fifth of the pointer speed (edge resistance 0.8) and clamps it back inside on release.
// The play button pops from 0.85 (back out 2), the skip buttons spin back from 30 degrees (back out 1.5) and the mute
// button pops from 0.8. Every control is a real button or slider with a label; the grip also moves with the arrow keys.
// Reduced motion starts paused, keeps the bar in view, and drops the pan and the pops. The clock stops off screen and in
// hidden tabs.
// mount(root, { photo (corgi-1 to corgi-6), length (clip length, s), autoplay ("on" or "off"), hide (s before the bar
//               hides, 0 = never), pan (photo pan strength, 0 = still), accent (progress), button (play button),
//               ink (play icon), text (text and icons), glass (panel tint) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-fpc");
  var img = root.querySelector(".ef-fpc__img");
  var bar = root.querySelector(".ef-fpc__bar");
  var handle = root.querySelector(".ef-fpc__handle");
  var play = root.querySelector(".ef-fpc__play");
  var back = root.querySelector(".ef-fpc__back"), fwd = root.querySelector(".ef-fpc__fwd");
  var mute = root.querySelector(".ef-fpc__mute");
  var timeline = root.querySelector(".ef-fpc__timeline"), volume = root.querySelector(".ef-fpc__volume");
  var cur = root.querySelector(".ef-fpc__cur"), dur = root.querySelector(".ef-fpc__dur");
  var ALT = { "corgi-1": "A corgi puppy against an orange wall", "corgi-2": "A corgi smiling in a field of flowers", "corgi-3": "A corgi sitting on a rock among autumn leaves", "corgi-4": "A corgi and a shaggy dog on a dirt road", "corgi-5": "A corgi lying on a white background", "corgi-6": "A corgi standing on a white background" };

  var opts = {}, raf = 0, last = 0, visible = true, destroyed = false, io = null, ro = null, offs = [], photo = "";
  var t = 0, playing = false, muted = false, vol = 1, scrubbing = false, shownText = "";
  var x = 0, y = 0, hy = 0, op = 0, sc = 1, idle = 0, hovering = false, focusIn = false, hidden = false, drag = null;
  var tweens = {};

  // GSAP's eases, written out
  function power2InOut(p) { return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; }
  function power2Out(p) { return 1 - Math.pow(1 - p, 3); }
  function power3Out(p) { return 1 - Math.pow(1 - p, 4); }
  function backOut(s) { return function (p) { var q = p - 1; return 1 + (s + 1) * q * q * q + s * q * q; }; }
  var back17 = backOut(1.7), back2 = backOut(2), back15 = backOut(1.5);

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function fmt(s) { s = Math.max(0, s); var m = Math.floor(s / 60), r = Math.floor(s % 60); return m + ":" + (r < 10 ? "0" : "") + r; }

  // one running tween per key, all driven by the same frame loop
  function tween(key, from, to, d, ease, apply) {
    if (reduce) { apply(to); delete tweens[key]; return; }
    tweens[key] = { from: from, to: to, d: Math.max(0.001, d), ease: ease, apply: apply, t: 0 };
    apply(from);
  }
  function render() { bar.style.transform = "translate3d(" + x.toFixed(2) + "px," + (y + hy).toFixed(2) + "px,0) scale(" + sc.toFixed(4) + ")"; bar.style.opacity = op.toFixed(3); }
  function setOp(v) { op = v; render(); }
  function setHy(v) { hy = v; render(); }
  function setSc(v) { sc = v; render(); }
  function pop(el, key, r0, s0, d, ease) {
    tween(key, 0, 1, d, ease, function (e) { el.style.transform = e >= 1 ? "" : "rotate(" + (r0 * (1 - e)).toFixed(2) + "deg) scale(" + (s0 + (1 - s0) * e).toFixed(4) + ")"; });
  }

  function show() {
    idle = 0;
    box.classList.remove("is-idle");
    if (!hidden && op >= 1 && !tweens.op) return;
    hidden = false;
    tween("op", op, 1, 0.4, power3Out, setOp);
    tween("hy", hy, 0, 0.4, power3Out, setHy);
  }
  function hide() {
    if (hovering || focusIn || drag || !playing || reduce) return;
    hidden = true;
    tween("op", op, 0, 0.6, power2InOut, setOp);
    tween("hy", hy, 15, 0.6, power2InOut, setHy);
    box.classList.add("is-idle");
  }

  function paint() {
    var L = opts.length, p = L ? t / L : 0;
    if (!scrubbing) timeline.value = String(t);
    timeline.style.setProperty("--ef-fpc-p", (p * 100).toFixed(3) + "%");
    var txt = fmt(t);
    if (txt !== shownText) { shownText = txt; cur.textContent = txt; timeline.setAttribute("aria-valuetext", txt + " of " + fmt(L)); }
    var k = reduce ? 0 : opts.pan;
    img.style.transform = k ? "scale(" + (1 + k * (0.1 + 0.06 * p)).toFixed(4) + ") translate3d(" + (k * (0.5 - p) * 6).toFixed(3) + "%," + (k * (0.5 - p) * 1.5).toFixed(3) + "%,0)" : "";
  }
  function setPlaying(on) {
    playing = on;
    play.classList.toggle("is-playing", on);
    play.setAttribute("aria-label", on ? "Pause" : "Play");
    if (on) idle = 0; else show();
  }
  function volIcon(v) {
    var lvl = v === 0 || muted ? "off" : v < 0.5 ? "low" : "high";
    mute.setAttribute("data-level", lvl);
    mute.setAttribute("aria-label", lvl === "off" ? "Unmute" : "Mute");
  }
  function setVolBar(v) { volume.value = String(v); volume.style.setProperty("--ef-fpc-p", v + "%"); }

  function tick(now) {
    raf = requestAnimationFrame(tick);
    var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden) return;
    for (var k in tweens) {
      var tw = tweens[k]; tw.t += dt;
      var p = Math.min(1, tw.t / tw.d);
      tw.apply(tw.from + (tw.to - tw.from) * tw.ease(p));
      if (p >= 1) delete tweens[k];
    }
    if (playing) {
      t += dt;
      if (t >= opts.length) t -= opts.length; // the clip loops, like the original's looping video
      paint();
      if (opts.hide > 0 && !hidden && !hovering && !focusIn && !drag) { idle += dt; if (idle >= opts.hide) hide(); }
    }
  }

  // controls
  function onPlay() { pop(play, "pPlay", 0, 0.85, 0.3, back2); setPlaying(!playing); }
  function skip(s) { t = clamp(t + s, 0, opts.length); paint(); }
  function onBack() { pop(back, "pBack", -30, 0.8, 0.4, back15); skip(-10); }
  function onFwd() { pop(fwd, "pFwd", 30, 0.8, 0.4, back15); skip(10); }
  function onSeek() { t = clamp(num(timeline.value, 0), 0, opts.length); paint(); }
  function onScrubStart() { scrubbing = true; }
  function onScrubEnd() { scrubbing = false; }
  function onVolume() { var v = num(volume.value, 0) / 100; vol = v; muted = v === 0; setVolBar(Math.round(v * 100)); volIcon(v); }
  function onMute() {
    pop(mute, "pMute", 0, 0.8, 0.3, back2);
    if (muted || vol === 0) { muted = false; vol = 0.5; setVolBar(50); volIcon(0.5); }
    else { muted = true; setVolBar(0); volIcon(0); }
  }

  // dragging by the grip: free inside the box, a fifth of the overshoot past an edge, clamped back on release
  function bounds() {
    return { x0: -bar.offsetLeft, x1: box.clientWidth - bar.offsetLeft - bar.offsetWidth, y0: -bar.offsetTop, y1: box.clientHeight - bar.offsetTop - bar.offsetHeight };
  }
  function resist(v, a, b) { return v < a ? a + (v - a) * 0.2 : v > b ? b + (v - b) * 0.2 : v; }
  function onDown(e) {
    if (e.button !== undefined && e.button !== 0) return;
    e.preventDefault();
    drag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, x0: x, y0: y, b: bounds() };
    try { handle.setPointerCapture(e.pointerId); } catch (err) {}
    handle.classList.add("is-drag");
    show();
    tween("sc", sc, 1.02, 0.2, back17, setSc);
  }
  function onMove(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var b = drag.b;
    x = resist(drag.x0 + e.clientX - drag.sx, Math.min(b.x0, 0), Math.max(b.x1, 0));
    y = resist(drag.y0 + e.clientY - drag.sy, Math.min(b.y0, 0), Math.max(b.y1, 0));
    render();
  }
  function onUp(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var b = drag.b;
    drag = null;
    handle.classList.remove("is-drag");
    x = clamp(x, Math.min(b.x0, 0), Math.max(b.x1, 0)); y = clamp(y, Math.min(b.y0, 0), Math.max(b.y1, 0));
    tween("sc", sc, 1, 0.2, power2Out, setSc);
    render();
    idle = 0;
  }
  function onHandleKey(e) {
    var step = e.shiftKey ? 40 : 10, dx = 0, dy = 0;
    if (e.key === "ArrowLeft") dx = -step; else if (e.key === "ArrowRight") dx = step;
    else if (e.key === "ArrowUp") dy = -step; else if (e.key === "ArrowDown") dy = step;
    else if (e.key === "Home") { e.preventDefault(); x = 0; y = 0; render(); return; }
    else return;
    e.preventDefault();
    var b = bounds();
    x = clamp(x + dx, Math.min(b.x0, 0), Math.max(b.x1, 0)); y = clamp(y + dy, Math.min(b.y0, 0), Math.max(b.y1, 0));
    render();
  }
  function fit() { var b = bounds(); x = clamp(x, Math.min(b.x0, 0), Math.max(b.x1, 0)); y = clamp(y, Math.min(b.y0, 0), Math.max(b.y1, 0)); render(); }

  // activity: any pointer movement, tap or key on the player brings the bar back and restarts the hide countdown
  function onActive() { show(); }
  function onEnter() { hovering = true; show(); }
  function onLeave() { hovering = false; idle = 0; }
  function onFocusIn() { focusIn = true; show(); }
  function onFocusOut() { focusIn = bar.contains(document.activeElement); idle = 0; }
  function on(el, ev, fn, opt) { el.addEventListener(ev, fn, opt); offs.push(function () { el.removeEventListener(ev, fn, opt); }); }

  function update(partial) {
    var prevAuto = opts.autoplay;
    opts = Object.assign({ photo: "corgi-4", length: 90, autoplay: "on", hide: 2.5, pan: 1, accent: "#e6ff55", button: "#fbf8f1", ink: "#0c1e18", text: "#fbf8f1", glass: "#050d0a" }, opts, partial || {});
    opts.length = Math.max(10, num(opts.length, 90)); opts.hide = Math.max(0, num(opts.hide, 2.5)); opts.pan = Math.max(0, num(opts.pan, 1));
    opts.autoplay = opts.autoplay === "off" || opts.autoplay === false ? "off" : "on";
    var s = box.style;
    s.setProperty("--ef-fpc-acc", opts.accent); s.setProperty("--ef-fpc-btn", opts.button); s.setProperty("--ef-fpc-ink", opts.ink);
    s.setProperty("--ef-fpc-text", opts.text); s.setProperty("--ef-fpc-glass", opts.glass);
    var ph = /^corgi-[1-6]$/.test(opts.photo) ? opts.photo : "corgi-4";
    if (ph !== photo) { photo = ph; img.src = "/img/library/" + ph + ".jpg"; img.alt = ALT[ph] + ", standing in for the video"; }
    timeline.max = String(opts.length);
    dur.textContent = fmt(opts.length);
    t = clamp(t, 0, opts.length);
    if (opts.autoplay !== prevAuto) setPlaying(opts.autoplay === "on" && !reduce);
    if (opts.hide === 0 || !playing) show();
    shownText = "";
    paint();
  }

  on(play, "click", onPlay);
  on(back, "click", onBack);
  on(fwd, "click", onFwd);
  on(mute, "click", onMute);
  on(timeline, "input", onSeek);
  on(timeline, "pointerdown", onScrubStart);
  on(timeline, "pointerup", onScrubEnd);
  on(timeline, "pointercancel", onScrubEnd);
  on(timeline, "blur", onScrubEnd);
  on(volume, "input", onVolume);
  on(handle, "pointerdown", onDown);
  on(handle, "pointermove", onMove);
  on(handle, "pointerup", onUp);
  on(handle, "pointercancel", onUp);
  on(handle, "keydown", onHandleKey);
  on(box, "pointermove", onActive, { passive: true });
  on(box, "pointerdown", onActive, { passive: true });
  on(box, "keydown", onActive);
  on(bar, "pointerenter", onEnter);
  on(bar, "pointerleave", onLeave);
  on(bar, "focusin", onFocusIn);
  on(bar, "focusout", onFocusOut);

  update(o);
  setVolBar(100); volIcon(1);
  if (reduce) { op = 1; render(); } else { tween("op", 0, 1, 0.4, power3Out, setOp); } // the bar fades in on load
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed && !drag) fit(); }); ro.observe(box); }
  last = performance.now(); raf = requestAnimationFrame(tick);
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf); tweens = {};
      offs.forEach(function (f) { f(); }); offs = [];
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"photo":"corgi-4","length":90,"autoplay":"on","hide":2.5,"pan":1,"accent":"#e6ff55","button":"#fbf8f1","ink":"#0c1e18","text":"#fbf8f1","glass":"#050d0a"});

How it works

A clock stands in for the video: while playing it advances a 1:30 playhead that loops at the end, and each frame sets the scrubber, the time and the photo, which zooms from 1.1 to 1.16 and pans a few percent across the clip, so scrubbing moves the picture too. The bar fades in on load (0.4 s power3 out). While playing, 2.5 seconds without pointer movement fades it out and sinks it 15 pixels (0.6 s power2 in-out) and hides the cursor; any movement, tap or key brings it back, and it never hides while paused, hovered or focused. Dragging uses pointer events on the grip, as GSAP Draggable did in the original: the bar follows the pointer, can go past an edge at a fifth of the pointer speed (edge resistance 0.8), and snaps back inside on release, while it scales to 1.02 (0.2 s back out 1.7) and back (0.2 s power2 out). The play button pops from 0.85 (0.3 s back out 2), the skip buttons spin back from 30 degrees and 0.8 scale (0.4 s back out 1.5), and the mute button pops from 0.8.

Accessibility and performance

Every control is a real button or range slider with a label, the scrubber reports its time as text, and the play and mute buttons update their labels. The grip is a button too: the arrow keys move the bar (Shift for bigger steps) and Home puts it back. Keyboard focus inside the bar keeps it shown. With prefers-reduced-motion it starts paused, the bar stays in view, and the photo pan and button pops are off.

One requestAnimationFrame loop drives the clock and the few tweens; each frame writes two transforms and one CSS variable. The photo is a single image moved with transform, so it stays on the compositor. The blur behind the bar is a CSS backdrop filter, which costs the most on slow phones; lower it in the CSS if needed. The clock stops off screen and in hidden tabs.

SectionFull page
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired bySleek Floating Video Player Controls by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

How do I connect a real video?

Put a video element in .ef-fpc__screen in place of the image. In the frame loop, read t from video.currentTime instead of adding time, call video.play() and video.pause() in setPlaying, and set video.currentTime in the skip and seek handlers.

Does the drag feel the same as the original?

Yes for the sideways feel: the same 1.02 lift, the same edge resistance and the same snap back inside on release. In the original the bar also slid back to its first height on the next mouse move, because the show tween reset its position; here it stays where you drop it.

Why can't I see the controls?

They hide after 2.5 seconds without movement while playing, like most video players. Move the pointer, tap, or press Tab to bring them back, or set Hide controls after to 0.

Where are the photos from?

Photos: Alvan Nee and fatty corgi on Unsplash, used under the Unsplash License. Pick another with the Photo setting.