diff --git a/internal/design/ui/controls-design.md b/internal/design/ui/controls-design.md
index 64ed15d8..9f3c34b3 100644
--- a/internal/design/ui/controls-design.md
+++ b/internal/design/ui/controls-design.md
@@ -142,8 +142,8 @@ Visual grouping container. No special behavior — pure layout.
```html
-
-
+
+
```
diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts
index 1fa0ff8b..ccea9b90 100644
--- a/packages/core/src/core/index.ts
+++ b/packages/core/src/core/index.ts
@@ -11,6 +11,8 @@ export * from './ui/play-button/play-button-core';
export * from './ui/play-button/play-button-data-attrs';
export * from './ui/poster/poster-core';
export * from './ui/poster/poster-data-attrs';
+export * from './ui/seek-button/seek-button-core';
+export * from './ui/seek-button/seek-button-data-attrs';
export * from './ui/time/time-core';
export * from './ui/time/time-data-attrs';
export * from './ui/types';
diff --git a/packages/core/src/core/ui/seek-button/seek-button-core.ts b/packages/core/src/core/ui/seek-button/seek-button-core.ts
new file mode 100644
index 00000000..8ee122e2
--- /dev/null
+++ b/packages/core/src/core/ui/seek-button/seek-button-core.ts
@@ -0,0 +1,79 @@
+import { defaults } from '@videojs/utils/object';
+import { isFunction } from '@videojs/utils/predicate';
+import type { NonNullableObject } from '@videojs/utils/types';
+
+import type { MediaTimeState } from '../../media/state';
+
+export interface SeekButtonProps {
+ /** Seconds to seek. Positive = forward, negative = backward. Default `30`. */
+ seconds?: number | undefined;
+ /** Custom label for the button. */
+ label?: string | ((state: SeekButtonState) => string) | undefined;
+ /** Whether the button is disabled. */
+ disabled?: boolean | undefined;
+}
+
+export type SeekButtonDirection = 'forward' | 'backward';
+
+export interface SeekButtonState {
+ /** Whether a seek is in progress. */
+ seeking: boolean;
+ /** Whether the button seeks forward or backward. */
+ direction: SeekButtonDirection;
+}
+
+export class SeekButtonCore {
+ static readonly defaultProps: NonNullableObject = {
+ seconds: 30,
+ label: '',
+ disabled: false,
+ };
+
+ #props = { ...SeekButtonCore.defaultProps };
+
+ constructor(props?: SeekButtonProps) {
+ if (props) this.setProps(props);
+ }
+
+ setProps(props: SeekButtonProps): void {
+ this.#props = defaults(props, SeekButtonCore.defaultProps);
+ }
+
+ getLabel(state: SeekButtonState): string {
+ const { label } = this.#props;
+
+ if (isFunction(label)) {
+ const customLabel = label(state);
+ if (customLabel) return customLabel;
+ } else if (label) {
+ return label;
+ }
+
+ const abs = Math.abs(this.#props.seconds);
+ return state.direction === 'backward' ? `Seek backward ${abs} seconds` : `Seek forward ${abs} seconds`;
+ }
+
+ getAttrs(state: SeekButtonState) {
+ return {
+ 'aria-label': this.getLabel(state),
+ 'aria-disabled': this.#props.disabled ? 'true' : undefined,
+ };
+ }
+
+ getState(media: MediaTimeState): SeekButtonState {
+ return {
+ seeking: media.seeking,
+ direction: this.#props.seconds < 0 ? 'backward' : 'forward',
+ };
+ }
+
+ async seek(media: MediaTimeState): Promise {
+ if (this.#props.disabled) return;
+ await media.seek(media.currentTime + this.#props.seconds);
+ }
+}
+
+export namespace SeekButtonCore {
+ export type Props = SeekButtonProps;
+ export type State = SeekButtonState;
+}
diff --git a/packages/core/src/core/ui/seek-button/seek-button-data-attrs.ts b/packages/core/src/core/ui/seek-button/seek-button-data-attrs.ts
new file mode 100644
index 00000000..8bb374e0
--- /dev/null
+++ b/packages/core/src/core/ui/seek-button/seek-button-data-attrs.ts
@@ -0,0 +1,9 @@
+import type { StateAttrMap } from '../types';
+import type { SeekButtonState } from './seek-button-core';
+
+export const SeekButtonDataAttrs = {
+ /** Present when a seek is in progress. */
+ seeking: 'data-seeking',
+ /** Indicates the seek direction: `"forward"` or `"backward"`. */
+ direction: 'data-direction',
+} as const satisfies StateAttrMap;
diff --git a/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts
new file mode 100644
index 00000000..5a7616eb
--- /dev/null
+++ b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { MediaTimeState } from '../../../media/state';
+import type { SeekButtonState } from '../seek-button-core';
+import { SeekButtonCore } from '../seek-button-core';
+
+function createMediaState(overrides: Partial = {}): MediaTimeState {
+ return {
+ currentTime: 0,
+ duration: 300,
+ seeking: false,
+ seek: vi.fn(async (time: number) => time),
+ ...overrides,
+ };
+}
+
+function createState(overrides: Partial = {}): SeekButtonState {
+ return {
+ seeking: false,
+ direction: 'forward',
+ ...overrides,
+ };
+}
+
+describe('SeekButtonCore', () => {
+ describe('setProps', () => {
+ it('uses default props', () => {
+ const core = new SeekButtonCore();
+ const state = core.getState(createMediaState());
+ expect(state.direction).toBe('forward');
+ });
+
+ it('accepts constructor props', () => {
+ const core = new SeekButtonCore({ seconds: -10 });
+ const state = core.getState(createMediaState());
+ expect(state.direction).toBe('backward');
+ });
+
+ it('accepts disabled via constructor', () => {
+ const core = new SeekButtonCore({ disabled: true });
+ const attrs = core.getAttrs(createState());
+ expect(attrs['aria-disabled']).toBe('true');
+ });
+ });
+
+ describe('getState', () => {
+ it('projects seeking from media state', () => {
+ const core = new SeekButtonCore();
+ const state = core.getState(createMediaState({ seeking: true }));
+ expect(state.seeking).toBe(true);
+ });
+
+ it('derives forward direction from positive seconds', () => {
+ const core = new SeekButtonCore({ seconds: 15 });
+ const state = core.getState(createMediaState());
+ expect(state.direction).toBe('forward');
+ });
+
+ it('derives backward direction from negative seconds', () => {
+ const core = new SeekButtonCore({ seconds: -15 });
+ const state = core.getState(createMediaState());
+ expect(state.direction).toBe('backward');
+ });
+
+ it('defaults to forward direction', () => {
+ const core = new SeekButtonCore();
+ const state = core.getState(createMediaState());
+ expect(state.direction).toBe('forward');
+ });
+ });
+
+ describe('getLabel', () => {
+ it('returns forward label for forward direction', () => {
+ const core = new SeekButtonCore({ seconds: 30 });
+ expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 30 seconds');
+ });
+
+ it('returns backward label for backward direction', () => {
+ const core = new SeekButtonCore({ seconds: -10 });
+ expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Seek backward 10 seconds');
+ });
+
+ it('uses absolute value in backward label', () => {
+ const core = new SeekButtonCore({ seconds: -30 });
+ const label = core.getLabel(createState({ direction: 'backward' }));
+ expect(label).toBe('Seek backward 30 seconds');
+ expect(label).not.toContain('-');
+ });
+
+ it('returns custom string label', () => {
+ const core = new SeekButtonCore({ label: 'Skip' });
+ expect(core.getLabel(createState())).toBe('Skip');
+ });
+
+ it('returns custom function label', () => {
+ const core = new SeekButtonCore({
+ label: (state) => (state.direction === 'backward' ? 'Rewind' : 'Skip ahead'),
+ });
+ expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Rewind');
+ expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Skip ahead');
+ });
+
+ it('falls back to default when function returns empty', () => {
+ const core = new SeekButtonCore({ seconds: 10, label: () => '' });
+ expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 10 seconds');
+ });
+ });
+
+ describe('getAttrs', () => {
+ it('returns aria-label', () => {
+ const core = new SeekButtonCore({ seconds: 30 });
+ const attrs = core.getAttrs(createState({ direction: 'forward' }));
+ expect(attrs['aria-label']).toBe('Seek forward 30 seconds');
+ });
+
+ it('sets aria-disabled when disabled', () => {
+ const core = new SeekButtonCore({ disabled: true });
+ const attrs = core.getAttrs(createState());
+ expect(attrs['aria-disabled']).toBe('true');
+ });
+
+ it('omits aria-disabled when not disabled', () => {
+ const core = new SeekButtonCore();
+ const attrs = core.getAttrs(createState());
+ expect(attrs['aria-disabled']).toBeUndefined();
+ });
+ });
+
+ describe('seek', () => {
+ it('seeks forward by seconds offset', async () => {
+ const core = new SeekButtonCore({ seconds: 30 });
+ const media = createMediaState({ currentTime: 60 });
+ await core.seek(media);
+ expect(media.seek).toHaveBeenCalledWith(90);
+ });
+
+ it('seeks backward by negative seconds offset', async () => {
+ const core = new SeekButtonCore({ seconds: -10 });
+ const media = createMediaState({ currentTime: 60 });
+ await core.seek(media);
+ expect(media.seek).toHaveBeenCalledWith(50);
+ });
+
+ it('does not clamp the target time', async () => {
+ const core = new SeekButtonCore({ seconds: -30 });
+ const media = createMediaState({ currentTime: 10 });
+ await core.seek(media);
+ // Clamping is the store's responsibility, not the button's.
+ expect(media.seek).toHaveBeenCalledWith(-20);
+ });
+
+ it('does nothing when disabled', async () => {
+ const core = new SeekButtonCore({ disabled: true, seconds: 30 });
+ const media = createMediaState({ currentTime: 60 });
+ await core.seek(media);
+ expect(media.seek).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/html/src/define/ui/seek-button.ts b/packages/html/src/define/ui/seek-button.ts
new file mode 100644
index 00000000..f4f11fe4
--- /dev/null
+++ b/packages/html/src/define/ui/seek-button.ts
@@ -0,0 +1,9 @@
+import { SeekButtonElement } from '../../ui/seek-button/seek-button-element';
+
+customElements.define(SeekButtonElement.tagName, SeekButtonElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [SeekButtonElement.tagName]: SeekButtonElement;
+ }
+}
diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts
index 33221e1e..bff75add 100644
--- a/packages/html/src/index.ts
+++ b/packages/html/src/index.ts
@@ -23,6 +23,7 @@ export { MuteButtonElement } from './ui/mute-button/mute-button-element';
export { PiPButtonElement } from './ui/pip-button/pip-button-element';
export { PlayButtonElement } from './ui/play-button/play-button-element';
export { PosterElement } from './ui/poster/poster-element';
+export { SeekButtonElement } from './ui/seek-button/seek-button-element';
export { TimeElement } from './ui/time/time-element';
export { TimeGroupElement } from './ui/time/time-group-element';
export { TimeSeparatorElement } from './ui/time/time-separator-element';
diff --git a/packages/html/src/ui/seek-button/seek-button-element.ts b/packages/html/src/ui/seek-button/seek-button-element.ts
new file mode 100644
index 00000000..8dda1e2b
--- /dev/null
+++ b/packages/html/src/ui/seek-button/seek-button-element.ts
@@ -0,0 +1,66 @@
+import { SeekButtonCore, SeekButtonDataAttrs } from '@videojs/core';
+import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature, selectTime } from '@videojs/core/dom';
+import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
+
+import { playerContext } from '../../player/context';
+import { PlayerController } from '../../player/player-controller';
+import { MediaElement } from '../media-element';
+
+export class SeekButtonElement extends MediaElement {
+ static readonly tagName = 'media-seek-button';
+
+ static override properties = {
+ seconds: { type: Number },
+ label: { type: String },
+ disabled: { type: Boolean },
+ } satisfies PropertyDeclarationMap;
+
+ seconds = SeekButtonCore.defaultProps.seconds;
+ label = SeekButtonCore.defaultProps.label;
+ disabled = SeekButtonCore.defaultProps.disabled;
+
+ readonly #core = new SeekButtonCore();
+ readonly #state = new PlayerController(this, playerContext, selectTime);
+
+ #disconnect: AbortController | null = null;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+
+ this.#disconnect = new AbortController();
+
+ const buttonProps = createButton({
+ onActivate: () => this.#core.seek(this.#state.value!),
+ isDisabled: () => this.disabled || !this.#state.value,
+ });
+
+ applyElementProps(this, buttonProps, this.#disconnect.signal);
+
+ if (__DEV__ && !this.#state.value) {
+ logMissingFeature(SeekButtonElement.tagName, 'time');
+ }
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.#disconnect?.abort();
+ this.#disconnect = null;
+ }
+
+ protected override willUpdate(changed: PropertyValues): void {
+ super.willUpdate(changed);
+ this.#core.setProps(this);
+ }
+
+ protected override update(changed: PropertyValues): void {
+ super.update(changed);
+
+ const media = this.#state.value;
+
+ if (!media) return;
+
+ const state = this.#core.getState(media);
+ applyElementProps(this, this.#core.getAttrs(state));
+ applyStateDataAttrs(this, state, SeekButtonDataAttrs);
+ }
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 6a6d33af..cb7a5de0 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -39,6 +39,7 @@ export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button';
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
export { Poster, type PosterProps } from './ui/poster/poster';
+export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button';
export { Time } from './ui/time';
// Utilities
diff --git a/packages/react/src/ui/seek-button/seek-button.tsx b/packages/react/src/ui/seek-button/seek-button.tsx
new file mode 100644
index 00000000..a675d461
--- /dev/null
+++ b/packages/react/src/ui/seek-button/seek-button.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { SeekButtonCore, SeekButtonDataAttrs } from '@videojs/core';
+import { logMissingFeature, selectTime } from '@videojs/core/dom';
+import type { ForwardedRef } from 'react';
+import { forwardRef, useState } from 'react';
+
+import { usePlayer } from '../../player/context';
+import type { UIComponentProps } from '../../utils/types';
+import { renderElement } from '../../utils/use-render';
+import { useButton } from '../hooks/use-button';
+
+export interface SeekButtonProps extends UIComponentProps<'button', SeekButtonCore.State>, SeekButtonCore.Props {}
+
+/**
+ * A button that seeks forward or backward by a configurable number of seconds.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * (
+ *
+ * )}
+ * />
+ * ```
+ */
+export const SeekButton = forwardRef(function SeekButton(
+ componentProps: SeekButtonProps,
+ forwardedRef: ForwardedRef
+) {
+ const { render, className, style, seconds, label, disabled, ...elementProps } = componentProps;
+
+ const time = usePlayer(selectTime);
+
+ const [core] = useState(() => new SeekButtonCore());
+ core.setProps({ seconds, label, disabled });
+
+ const { getButtonProps, buttonRef } = useButton({
+ displayName: 'SeekButton',
+ onActivate: () => core.seek(time!),
+ isDisabled: () => disabled || !time,
+ });
+
+ if (!time) {
+ if (__DEV__) logMissingFeature('SeekButton', 'time');
+ return null;
+ }
+
+ const state = core.getState(time);
+
+ return renderElement(
+ 'button',
+ { render, className, style },
+ {
+ state,
+ stateAttrMap: SeekButtonDataAttrs,
+ ref: [forwardedRef, buttonRef],
+ props: [core.getAttrs(state), elementProps, getButtonProps()],
+ }
+ );
+});
+
+export namespace SeekButton {
+ export type Props = SeekButtonProps;
+ export type State = SeekButtonCore.State;
+}