8.4 KiB
status, date
| status | date |
|---|---|
| draft | 2026-02-26 |
PlaybackRateButton
Button for cycling through playback speeds. Displays the current rate and advances to the next on click.
Problem
Users want to speed up or slow down video playback. A button that cycles through preset rates is a common pattern — compact, one-click, no menu required.
Two things are needed:
- Store feature —
playbackRateisn't tracked in the store yet. No state, no selector, no action. - Button component — Three-layer implementation (Core + HTML + React) following existing button patterns.
Store: New MediaPlaybackRateState
Playback rate gets its own feature and state slice — separate from MediaPlaybackState.
State
export interface MediaPlaybackRateState {
readonly playbackRates: readonly number[];
playbackRate: number;
setPlaybackRate(rate: number): void;
}
playbackRates is a readonly array of available rates, statically set to [1, 1.2, 1.5, 1.7, 2] for now. Later, providers can populate this dynamically (e.g., YouTube's getAvailablePlaybackRates).
Feature
const DEFAULT_RATES: readonly number[] = [1, 1.2, 1.5, 1.7, 2];
export const playbackRateFeature = createFeature({
state: ({ target }): MediaPlaybackRateState => ({
playbackRates: DEFAULT_RATES,
playbackRate: 1,
setPlaybackRate(rate: number) {
target().media.playbackRate = rate;
},
}),
attach({ target, signal, set }) {
const { media } = target;
const sync = () => set({ playbackRate: media.playbackRate });
sync();
listen(media, "ratechange", sync, { signal });
},
});
Why a Separate Feature
The alternative is extending playbackFeature — adding playbackRate and setPlaybackRate to existing MediaPlaybackState. Conceptually cohesive since rate is a property of playback, and the native API puts it on the same HTMLMediaElement. But coupling means every playback subscriber re-renders on rate change, and the state can't independently track capability/support.
A separate feature wins out (per review):
- Self-contained capability — Rate support varies by provider (e.g., Vimeo required Pro, YouTube exposes
getAvailablePlaybackRates). A separate feature can later track availability without bloating the playback state. - Used independently in UI — The playback rate button only needs rate state, not
paused/ended/waiting. - Granular contracts — Aligns with the pattern of capability-scoped features.
Component
Usage
Headless — no default text or icons, matching all other buttons. Consumers show the rate via data-rate or the render prop.
React
import { PlaybackRateButton } from "@videojs/react";
<PlaybackRateButton />;
{
/* With rate display */
}
<PlaybackRateButton
render={(props, state) => <button {...props}>{state.rate}×</button>}
/>;
HTML
<media-playback-rate-button></media-playback-rate-button>
/* Rate display via CSS */
media-playback-rate-button::after {
content: attr(data-rate) "\00D7";
}
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string | ((state: PlaybackRateButtonState) => string) |
— | Custom accessible label. Falls back to default text. |
disabled |
boolean |
false |
Disables interaction. |
No rates prop — rates are sourced from playbackRates in the store. A consumer-facing prop is deferred until provider rate constraints are sorted out.
Data Attributes
| Attribute | Values | Description |
|---|---|---|
data-rate |
Number (e.g., 1.5) |
Current playback rate. |
State
interface PlaybackRateButtonState {
rate: number;
}
Behavior
On click, advance to the next rate in the playbackRates list. Wrap to the beginning after the last rate:
playbackRates = [1, 1.2, 1.5, 1.7, 2]
Click: 1 → 1.2 → 1.5 → 1.7 → 2 → 1 → ...
If the current rate isn't in the list (e.g., set programmatically to 0.75), find the first rate in array order that's greater. If none is greater, wrap to the first rate.
cycle method
cycle(media: MediaPlaybackRateState): void {
if (this.#props.disabled) return;
const { playbackRates, playbackRate } = media;
if (playbackRates.length === 0) return;
const idx = playbackRates.indexOf(playbackRate);
const next = idx === -1
? playbackRates.find((r) => r > playbackRate) ?? playbackRates[0]
: playbackRates[(idx + 1) % playbackRates.length];
media.setPlaybackRate(next);
}
Accessibility
Keyboard
| Key | Action |
|---|---|
Enter |
Cycle to next rate |
Space |
Cycle to next rate |
ARIA
aria-label: Default"Playback rate {rate}"(e.g.,"Playback rate 1.5"). Updates on rate change. Matches Media Chrome's format.aria-disabled: Present whendisabledprop is true.
Deferred: Custom rates Prop
Custom rates are intentionally deferred (per review). Some providers only support specific playback rates (e.g., YouTube's getAvailablePlaybackRates), so exposing a consumer-facing rates prop without accounting for provider constraints could lead to broken UX. The store's playbackRates array is the right place — providers can populate it dynamically in the future.
When we're ready to expose custom rates, the following research applies:
Proposed API: <PlaybackRateButton rates={[0.5, 1, 1.5, 2]} /> in React, rates="0.5 1 1.5 2" (space-separated) in HTML. A component rates prop would override the store's playbackRates for that instance.
ReactiveElement converter: The HTML attribute needs a converter option on ReactiveElement since it only handles String, Boolean, and Number today. The proposed approach adds a converter function to PropertyDeclaration, aligning with Lit's converter API (~10 lines of implementation). General-purpose for any future attribute needing custom parsing.
export interface PropertyDeclaration {
readonly type?: typeof String | typeof Boolean | typeof Number;
readonly attribute?: string;
readonly converter?: (value: string | null) => unknown;
}
Element declaration with converter:
static override properties = {
rates: {
converter(value) {
const defaults = PlaybackRateButtonCore.defaultProps.rates;
if (!value) return defaults;
const parsed = value.split(/\s+/).map(Number).filter((n) => !Number.isNaN(n));
return parsed.length > 0 ? parsed : defaults;
},
},
};
No auto-sorting: Rates are cycled in array order, not sorted numerically. Media Chrome sorts with .sort((a, b) => a - b), but we respect the consumer's order. Non-linear sequences (e.g., [1, 2, 1.5]) are valid.
Alternatives explored:
- Property-only (no attribute) — Standard web component convention for complex types, but forces JS-only configuration and undermines the declarative HTML story.
- Custom getter — Non-enumerable, breaks
setProps(this)andsatisfiesconstraint. AttributeTokenList— Media Chrome'sDOMTokenList-like approach. More infrastructure than needed.Arraybuilt-in type withJSON.parse— Producesrates='[1, 1.5, 2]'which is awkward for HTML authors.min/max/step— Vidstack's approach for continuous ranges. Doesn't allow non-uniform spacing or arbitrary ordering.
References
- Media Chrome: Playback Rate Button — Default rates
[1, 1.2, 1.5, 1.7, 2], cycle on click HTMLMediaElement.playbackRate(MDN)- Issue #622