Glass Media Controls

Clear-glass playback controls over video: transport, scrubber and volume capsules, with the HIG’s 35% dimming layer.

  • Liquid Glass
  • Music & media
  • React only · 0 deps
  • 2 files
Full screen

Browser support · Real refraction (SVG displacement inside backdrop-filter) in Chromium browsers. Safari and Firefox fall back to a frosted blur with the same rim and highlights.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/glass-media-controls.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-glass/glass-media-controls.tsx
1/**
2 * GlassMediaControls — clear Liquid Glass playback controls for video/music.
3 *
4 * Clear glass is the HIG variant for controls over rich media: highly
5 * translucent so the picture shows through, with a refracting rim. Over bright
6 * footage, turn on `dim` for the 35% dimming layer so white glyphs stay legible.
7 *
8 * Pieces (all clear glass, separate capsules — no glass on glass):
9 * - transport: back 10 s · play/pause (XL) · forward 10 s
10 * - scrubber capsule: elapsed · track · remaining (thumb turns to glass while dragged)
11 * - volume capsule: mute toggle + level track
12 *
13 * Props: playing + onPlayingChange, time + duration (s) + onSeek(s),
14 * volume (0–1) + onVolumeChange, muted + onMutedChange, dim, title.
15 * Keyboard: all real <button>s and <input type="range">s (Arrow/Home/End).
16 * Browser support: refraction in Chromium; frosted glass elsewhere.
17 * Reduced motion: no press bounce or knob swell.
18 */
19import { useEffect, useRef, useState } from "react";
20import type { CSSProperties, ReactNode } from "react";
21import { GlassScope, LiquidGlass, useGlassEnv, useSpring } from "./liquid-glass";
22
23export interface GlassMediaControlsProps {
24 playing: boolean;
25 onPlayingChange: (p: boolean) => void;
26 time: number;
27 duration: number;
28 onSeek: (t: number) => void;
29 volume: number;
30 onVolumeChange: (v: number) => void;
31 muted: boolean;
32 onMutedChange: (m: boolean) => void;
33 dim?: boolean;
34 /** Accessible name of the media, e.g. "Tidelines — episode 3". */
35 title: string;
36 className?: string;
37}
38
39const fmt = (s: number) => {
40 const t = Math.max(0, Math.round(s));
41 const m = Math.floor(t / 60);
42 return `${m}:${String(t % 60).padStart(2, "0")}`;
43};
44
45/** Thin range track with a knob that becomes glass while dragged. */
46function Track({ value, max, step, label, valueText, onChange }: { value: number; max: number; step: number; label: string; valueText: string; onChange: (v: number) => void }) {
47 const env = useGlassEnv();
48 const knob = useRef<HTMLElement | null>(null);
49 const [active, setActive] = useState(false);
50 const lift = useSpring(0, (v) => {
51 const k = knob.current;
52 if (!k) return;
53 k.style.setProperty("--fd-gmc-k", v.toFixed(3));
54 }, { stiffness: 520, damping: 28 });
55 useEffect(() => {
56 if (env.reducedMotion) lift.set(active ? 1 : 0);
57 else lift.to(active ? 1 : 0);
58 }, [active, env.reducedMotion, lift]);
59 const pct = max ? Math.min(1, Math.max(0, value / max)) : 0;
60 return (
61 <span className={`fd-gmc-track${active ? " is-active" : ""}`} style={{ "--p": pct } as CSSProperties}>
62 <span className="fd-gmc-rail" aria-hidden="true">
63 <span className="fd-gmc-fill" />
64 </span>
65 <LiquidGlass ref={knob} as="span" className="fd-gmc-knob" variant="clear" radius="capsule" refraction={1.3} shadow={false} aria-hidden="true">
66 <span className="fd-gmc-knob-solid" />
67 </LiquidGlass>
68 <input
69 type="range"
70 className="fd-gmc-input"
71 min={0}
72 max={max}
73 step={step}
74 value={value}
75 aria-label={label}
76 aria-valuetext={valueText}
77 onChange={(e) => onChange(Number(e.target.value))}
78 onPointerDown={() => setActive(true)}
79 onPointerUp={() => setActive(false)}
80 onPointerCancel={() => setActive(false)}
81 onBlur={() => setActive(false)}
82 />
83 </span>
84 );
85}
86
87const I = ({ children, size = 24 }: { children: ReactNode; size?: number }) => (
88 <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
89 {children}
90 </svg>
91);
92
93export function GlassMediaControls({ playing, onPlayingChange, time, duration, onSeek, volume, onVolumeChange, muted, onMutedChange, dim = false, title, className }: GlassMediaControlsProps) {
94 const vol = muted ? 0 : volume;
95 return (
96 <GlassScope>
97 <div className={["fd-gmc", className ?? ""].filter(Boolean).join(" ")} role="group" aria-label={`Playback controls — ${title}`}>
98 <style>{css}</style>
99 <div className="fd-gmc-transport">
100 <LiquidGlass as="button" type="button" className="fd-gmc-btn" variant="clear" dim={dim} interactive aria-label="Back 10 seconds" onClick={() => onSeek(Math.max(0, time - 10))}>
101 <Skip back />
102 </LiquidGlass>
103 <LiquidGlass as="button" type="button" className="fd-gmc-btn fd-gmc-play" variant="clear" dim={dim} interactive aria-label={playing ? "Pause" : "Play"} aria-pressed={playing} onClick={() => onPlayingChange(!playing)}>
104 <span className={`fd-gmc-pp${playing ? " is-playing" : ""}`} aria-hidden="true">
105 <I size={34}>
106 <path className="fd-gmc-pp-a" d="M7 4.6v14.8c0 .9 1 1.4 1.7.9l11.2-7.4a1.1 1.1 0 0 0 0-1.8L8.7 3.7C8 3.2 7 3.7 7 4.6Z" />
107 </I>
108 <I size={34}>
109 <rect x="5.5" y="4" width="4.6" height="16" rx="1.4" />
110 <rect x="13.9" y="4" width="4.6" height="16" rx="1.4" />
111 </I>
112 </span>
113 </LiquidGlass>
114 <LiquidGlass as="button" type="button" className="fd-gmc-btn" variant="clear" dim={dim} interactive aria-label="Forward 10 seconds" onClick={() => onSeek(Math.min(duration, time + 10))}>
115 <Skip />
116 </LiquidGlass>
117 </div>
118
119 <div className="fd-gmc-bar">
120 <LiquidGlass className="fd-gmc-scrub" variant="clear" dim={dim}>
121 <span className="fd-gmc-time">{fmt(time)}</span>
122 <Track value={time} max={duration} step={1} label="Seek" valueText={`${fmt(time)} of ${fmt(duration)}`} onChange={onSeek} />
123 <span className="fd-gmc-time">−{fmt(duration - time)}</span>
124 </LiquidGlass>
125 <LiquidGlass className="fd-gmc-vol" variant="clear" dim={dim}>
126 <button type="button" className="fd-gmc-mute" aria-label={muted ? "Unmute" : "Mute"} aria-pressed={muted} onClick={() => onMutedChange(!muted)}>
127 <I size={20}>
128 <path d="M4 9.2h3.3L12 5v14l-4.7-4.2H4a1 1 0 0 1-1-1v-3.6a1 1 0 0 1 1-1Z" />
129 {vol === 0 ? (
130 <path d="m15.5 9.5 5 5m0-5-5 5" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" fill="none" />
131 ) : (
132 <>
133 <path d="M15 9.2a4 4 0 0 1 0 5.6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" fill="none" />
134 {vol > 0.5 ? <path d="M17.6 6.6a7.6 7.6 0 0 1 0 10.8" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" fill="none" /> : null}
135 </>
136 )}
137 </I>
138 </button>
139 <Track
140 value={Math.round(vol * 100)}
141 max={100}
142 step={1}
143 label="Volume"
144 valueText={`${Math.round(vol * 100)}%`}
145 onChange={(v) => {
146 onVolumeChange(v / 100);
147 if (muted && v > 0) onMutedChange(false);
148 }}
149 />
150 </LiquidGlass>
151 </div>
152 </div>
153 </GlassScope>
154 );
155}
156
157function Skip({ back = false }: { back?: boolean }) {
158 return (
159 <svg viewBox="0 0 28 28" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={back ? undefined : { transform: "scaleX(-1)" }}>
160 <path d="M9.5 6.5A9.5 9.5 0 1 1 4.6 15" />
161 <path d="M9.8 2.6 9.5 6.5l3.9.6" />
162 <text x="14" y="18" textAnchor="middle" fill="currentColor" stroke="none" fontSize="8.5" fontWeight="700" fontFamily="ui-sans-serif,-apple-system,Inter,sans-serif" style={back ? undefined : { transform: "scaleX(-1)", transformOrigin: "14px 14px" }}>
163 10
164 </text>
165 </svg>
166 );
167}
168
169const css = `
170.fd-gmc,.fd-gmc *,.fd-gmc *::before,.fd-gmc *::after{box-sizing:border-box}
171.fd-gmc{display:flex;flex-direction:column;align-items:center;gap:18px;width:100%;margin:0;padding:0;color:#fff;font:600 13px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:normal}
172.fd-gmc-transport{display:flex;align-items:center;gap:22px;margin:0;padding:0}
173.fd-gmc-btn{display:grid;place-items:center;width:58px;height:58px;margin:0;padding:0;color:#fff}
174.fd-gmc-btn svg{display:block}
175.fd-gmc-play{width:84px;height:84px}
176.fd-gmc-pp{position:relative;display:block;width:34px;height:34px;margin:0;padding:0}
177.fd-gmc-pp svg{position:absolute;inset:0;transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}
178.fd-gmc-pp svg:first-child{transform:translateX(2px)}
179.fd-gmc-pp svg:last-child{opacity:0;transform:scale(0.6)}
180.fd-gmc-pp.is-playing svg:first-child{opacity:0;transform:translateX(2px) scale(0.6)}
181.fd-gmc-pp.is-playing svg:last-child{opacity:1;transform:none}
182.fd-gmc-bar{display:flex;align-items:center;gap:12px;width:100%;margin:0;padding:0}
183.fd-gmc-scrub{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:14px;height:52px;margin:0;padding:0 20px}
184.fd-gmc-vol{flex:0 0 200px;display:flex;align-items:center;gap:10px;height:52px;margin:0;padding:0 18px 0 8px}
185.fd-gmc-time{flex:none;min-width:40px;margin:0;padding:0;font:600 13px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;font-variant-numeric:tabular-nums;color:rgba(255,255,255,0.92);text-align:center}
186.fd-gmc-mute{flex:none;display:grid;place-items:center;width:36px;height:36px;margin:0;padding:0;border:0;border-radius:50%;background:none;color:#fff;cursor:pointer;-webkit-appearance:none;appearance:none;transition:background-color var(--fd-dur-1) linear}
187.fd-gmc-mute:hover{background:rgba(255,255,255,0.12)}
188.fd-gmc-mute svg{display:block}
189.fd-gmc-mute:focus-visible,.fd-gmc-btn:focus-visible{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:2px}
190.fd-gmc-track{position:relative;flex:1 1 auto;min-width:40px;height:28px;display:block;margin:0;padding:0}
191.fd-gmc-rail{position:absolute;left:0;right:0;top:50%;height:6px;margin:-3px 0 0;padding:0;display:block;border-radius:999px;background:rgba(255,255,255,0.28);overflow:hidden;transition:height var(--fd-dur-2) var(--fd-ease-out),margin var(--fd-dur-2) var(--fd-ease-out)}
192.fd-gmc-track.is-active .fd-gmc-rail{height:10px;margin-top:-5px}
193.fd-gmc-fill{position:absolute;left:0;top:0;bottom:0;width:calc(var(--p) * 100%);display:block;margin:0;padding:0;background:#fff;border-radius:inherit}
194.fd-gmc-knob{position:absolute !important;top:50%;left:calc(var(--p) * (100% - 22px));width:22px;height:22px;margin:-11px 0 0;padding:0;display:block;pointer-events:none;
195 transform:scale(calc(0.55 + var(--fd-gmc-k,0) * 0.95));--fd-lg-shadow:0 2px 6px rgba(0,0,0,0.3)}
196.fd-gmc-knob-solid{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:inherit;background:#fff;opacity:calc(1 - var(--fd-gmc-k,0) * 1.15)}
197.fd-gmc-knob .fd-lg-spec{opacity:var(--fd-gmc-k,0)}
198.fd-gmc-input{position:absolute;inset:-8px 0;width:100%;height:calc(100% + 16px);margin:0;padding:0;border:0;opacity:0;cursor:pointer;-webkit-appearance:none;appearance:none;background:none}
199.fd-gmc-track:has(.fd-gmc-input:focus-visible) .fd-gmc-rail{box-shadow:0 0 0 2px color-mix(in srgb,#0A84FF 70%,transparent)}
200@media (max-width:640px){
201 .fd-gmc-bar{flex-direction:column;align-items:stretch}
202 .fd-gmc-vol{flex:none}
203 .fd-gmc-transport{gap:16px}
204}
205@media (prefers-reduced-motion: reduce){.fd-gmc-pp svg{transition-duration:120ms;transform:none !important}}
206`;
207
208/* ───────────────────────── demo: a travel film player ───────────────────────── */
209
210const DURATION = 7 * 60 + 42;
211
212export function Demo() {
213 const [playing, setPlaying] = useState(false);
214 const [time, setTime] = useState(134);
215 const [volume, setVolume] = useState(0.72);
216 const [muted, setMuted] = useState(false);
217 const [dim, setDim] = useState(true);
218 const [shown, setShown] = useState(true);
219 const stage = useRef<HTMLDivElement | null>(null);
220
221 // play clock
222 useEffect(() => {
223 if (!playing) return;
224 const id = window.setInterval(() => setTime((t) => (t + 1 >= DURATION ? 0 : t + 1)), 1000);
225 return () => window.clearInterval(id);
226 }, [playing]);
227
228 // auto-hide chrome while playing; any pointer move brings it back
229 useEffect(() => {
230 if (!playing) {
231 setShown(true);
232 return;
233 }
234 let t = window.setTimeout(() => setShown(false), 3200);
235 const wake = () => {
236 setShown(true);
237 window.clearTimeout(t);
238 t = window.setTimeout(() => setShown(false), 3200);
239 };
240 const el = stage.current;
241 el?.addEventListener("pointermove", wake);
242 el?.addEventListener("keydown", wake);
243 return () => {
244 window.clearTimeout(t);
245 el?.removeEventListener("pointermove", wake);
246 el?.removeEventListener("keydown", wake);
247 };
248 }, [playing]);
249
250 return (
251 <div ref={stage} className={`fd-gmcd${playing ? " is-playing" : ""}${shown ? "" : " is-hidden"}`}>
252 <style>{demoCss}</style>
253 <div className="fd-gmcd-film" aria-hidden="true">
254 <div className="fd-gmcd-sky" />
255 <div className="fd-gmcd-sun" />
256 <div className="fd-gmcd-m3" />
257 <div className="fd-gmcd-m2" />
258 <div className="fd-gmcd-m1" />
259 <div className="fd-gmcd-lake" />
260 <div className="fd-gmcd-grain" />
261 </div>
262 <div className="fd-gmcd-top">
263 <div className="fd-gmcd-meta">
264 <p className="fd-gmcd-kicker">Northbound · Episode 3</p>
265 <h1 className="fd-gmcd-title">The Lofoten Light</h1>
266 </div>
267 <button type="button" role="switch" aria-checked={dim} className="fd-gmcd-dim" onClick={() => setDim((d) => !d)}>
268 <span className="fd-gmcd-dim-dot" aria-hidden="true" />
269 Dimming layer {dim ? "on" : "off"}
270 </button>
271 </div>
272 <div className="fd-gmcd-controls">
273 <GlassMediaControls
274 title="Northbound, episode 3: The Lofoten Light"
275 playing={playing}
276 onPlayingChange={setPlaying}
277 time={time}
278 duration={DURATION}
279 onSeek={setTime}
280 volume={volume}
281 onVolumeChange={setVolume}
282 muted={muted}
283 onMutedChange={setMuted}
284 dim={dim}
285 />
286 </div>
287 </div>
288 );
289}
290
291const F = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;
292const D = `"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif`;
293const demoCss = `
294.fd-gmcd,.fd-gmcd *,.fd-gmcd *::before,.fd-gmcd *::after{box-sizing:border-box}
295.fd-gmcd{position:relative;height:100vh;min-height:620px;width:100%;margin:0;padding:0;overflow:hidden;background:#0B0B0F;color:#fff;font:400 15px/1.4 ${F};letter-spacing:normal}
296.fd-gmcd-film{position:absolute;inset:0;overflow:hidden}
297.fd-gmcd-film > div{position:absolute;margin:0;padding:0;display:block}
298.fd-gmcd-sky{inset:0;background:linear-gradient(180deg,#FDE7C8 0%,#FFC9A3 22%,#FF9E8A 40%,#E86F9A 56%,#7C5BC4 78%,#2B3A8C 100%)}
299.fd-gmcd-sun{left:50%;top:44%;width:240px;height:240px;margin:-120px 0 0 -120px !important;border-radius:50%;background:radial-gradient(circle,#FFFDF2 0 22%,rgba(255,240,200,0.9) 26%,rgba(255,210,170,0.4) 46%,transparent 70%);animation:fd-gmcd-drift 22s ease-in-out infinite alternate;animation-play-state:paused}
300.fd-gmcd-m3{left:-5%;right:-5%;bottom:34%;height:34%;background:#B66C9F;clip-path:polygon(0 100%,0 60%,8% 42%,14% 52%,22% 24%,30% 46%,38% 30%,47% 50%,55% 20%,63% 44%,72% 28%,80% 48%,88% 34%,100% 52%,100% 100%);opacity:0.75;animation:fd-gmcd-pan 30s linear infinite alternate;animation-play-state:paused}
301.fd-gmcd-m2{left:-5%;right:-5%;bottom:30%;height:30%;background:#6F3F8A;clip-path:polygon(0 100%,0 52%,10% 30%,18% 46%,27% 12%,36% 40%,44% 22%,52% 44%,61% 8%,70% 36%,79% 24%,88% 46%,96% 30%,100% 40%,100% 100%);animation:fd-gmcd-pan 24s linear infinite alternate-reverse;animation-play-state:paused}
302.fd-gmcd-m1{left:-5%;right:-5%;bottom:22%;height:24%;background:#2C1D4F;clip-path:polygon(0 100%,0 38%,7% 52%,15% 26%,24% 48%,34% 30%,42% 56%,52% 40%,60% 60%,70% 34%,78% 50%,90% 22%,100% 46%,100% 100%)}
303.fd-gmcd-lake{left:0;right:0;bottom:0;height:24%;background:
304 repeating-linear-gradient(180deg,rgba(255,240,225,0) 0 6px,rgba(255,240,225,0.5) 6px 7.5px,rgba(255,240,225,0) 7.5px 12px),
305 linear-gradient(180deg,#4A2F78 0%,#B0679E 22%,#F29A93 55%,#FFD3A8 100%)}
306.fd-gmcd-lake::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:180px;margin-left:-90px;background:repeating-linear-gradient(180deg,rgba(255,244,220,0) 0 4px,rgba(255,244,220,0.85) 4px 5.5px,rgba(255,244,220,0) 5.5px 10px);
307 -webkit-mask:radial-gradient(ellipse 50% 100% at 50% 0,#000 0 30%,transparent 100%);mask:radial-gradient(ellipse 50% 100% at 50% 0,#000 0 30%,transparent 100%)}
308.fd-gmcd-grain{inset:0;background:radial-gradient(ellipse at 50% 40%,transparent 50%,rgba(0,0,0,0.35) 100%)}
309.fd-gmcd.is-playing .fd-gmcd-sun,.fd-gmcd.is-playing .fd-gmcd-m3,.fd-gmcd.is-playing .fd-gmcd-m2{animation-play-state:running}
310@keyframes fd-gmcd-drift{to{transform:translate(40px,30px)}}
311@keyframes fd-gmcd-pan{to{transform:translateX(3%)}}
312.fd-gmcd-top{position:absolute;left:0;right:0;top:0;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin:0;padding:28px 32px;
313 background:linear-gradient(180deg,rgba(0,0,0,0.28),transparent);transition:opacity 420ms cubic-bezier(0.22,1,0.36,1),transform 420ms cubic-bezier(0.22,1,0.36,1)}
314.fd-gmcd-meta{margin:0;padding:0;text-shadow:0 1px 12px rgba(0,0,0,0.25)}
315.fd-gmcd-kicker{margin:0 0 6px;font:600 13px/1.2 ${F};letter-spacing:0.08em;text-transform:uppercase;color:rgba(255,255,255,0.9)}
316.fd-gmcd-title{margin:0;font:700 clamp(2rem,4.4vw,3.5rem)/1 ${D};letter-spacing:-0.035em;color:#fff}
317.fd-gmcd-dim{display:inline-flex;align-items:center;gap:8px;flex:none;margin:0;padding:9px 14px;border:0;border-radius:999px;background:rgba(0,0,0,0.38);color:#fff;font:600 13px/1 ${F};cursor:pointer;-webkit-appearance:none;appearance:none;
318 -webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}
319.fd-gmcd-dim-dot{display:block;width:10px;height:10px;border-radius:50%;background:#8E8E93;transition:background-color 240ms}
320.fd-gmcd-dim[aria-checked="true"] .fd-gmcd-dim-dot{background:#30D158}
321.fd-gmcd-dim:focus-visible{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:2px}
322.fd-gmcd-controls{position:absolute;left:50%;bottom:32px;width:min(760px,calc(100% - 32px));margin:0;padding:0;transform:translateX(-50%);transition:opacity 420ms cubic-bezier(0.22,1,0.36,1),transform 420ms cubic-bezier(0.22,1,0.36,1)}
323.fd-gmcd.is-hidden .fd-gmcd-controls{opacity:0;transform:translate(-50%,16px);pointer-events:none}
324.fd-gmcd.is-hidden .fd-gmcd-top{opacity:0;transform:translateY(-10px)}
325.fd-gmcd.is-hidden{cursor:none}
326@media (max-width:640px){
327 .fd-gmcd-top{flex-direction:column;padding:20px 16px}
328 .fd-gmcd-controls{bottom:20px}
329}
330@media (prefers-reduced-motion: reduce){.fd-gmcd-sun,.fd-gmcd-m3,.fd-gmcd-m2{animation:none}}
331`;

More Liquid Glass. Same file contract.

Liquid Glass

The material itself: a lens that refracts what’s behind it, with squircle bevel, specular rim, dispersion and spring physics.

Glass Button

glass, glassProminent, clear and clear-prominent capsules in four sizes, with a springy press and a glow from the touch point.

Glass Tab Bar

Floating iOS 26 tab bar: a liquid lens springs between tabs, search sits apart, and it minimizes as you scroll.