diff --git a/biome.json b/biome.json
index 5376403a..e5659257 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
"html": {
"experimentalFullSupportEnabled": false
},
diff --git a/packages/icons/README.md b/packages/icons/README.md
index 498e8667..c17ce4ee 100644
--- a/packages/icons/README.md
+++ b/packages/icons/README.md
@@ -1,5 +1,116 @@
# @videojs/icons
-**Note:** This package is currently a placeholder and will be populated closer to Beta release.
+SVG icon library for Video.js. Ships optimized icons as **React components** and **HTML strings**, organized into icon sets.
-For now, custom icons are bundled directly with skins.
+## Icon Sets
+
+Icons are grouped into visual sets. Each set contains the same icon names with different designs.
+
+| Set | Description |
+| --- | --- |
+| `default` | Standard icon set used by the default skin |
+| `minimal` | Simplified icon set used by the minimal skin |
+
+### Available Icons
+
+| Icon | React Export | HTML Export |
+| --- | --- | --- |
+| `fullscreen-enter` | `FullscreenEnterIcon` | `fullscreenEnterIcon` |
+| `fullscreen-exit` | `FullscreenExitIcon` | `fullscreenExitIcon` |
+| `pause` | `PauseIcon` | `pauseIcon` |
+| `pip` | `PipIcon` | `pipIcon` |
+| `play` | `PlayIcon` | `playIcon` |
+| `restart` | `RestartIcon` | `restartIcon` |
+| `seek` | `SeekIcon` | `seekIcon` |
+| `spinner` | `SpinnerIcon` | `spinnerIcon` |
+| `volume-high` | `VolumeHighIcon` | `volumeHighIcon` |
+| `volume-low` | `VolumeLowIcon` | `volumeLowIcon` |
+| `volume-off` | `VolumeOffIcon` | `volumeOffIcon` |
+
+## Usage
+
+### React
+
+Import icons as React components. They accept standard SVG props and support ref forwarding.
+
+```tsx
+import { PlayIcon, PauseIcon } from '@videojs/icons/react';
+
+function Controls() {
+ return (
+
+ );
+}
+```
+
+Import from a specific icon set:
+
+```tsx
+import { PlayIcon } from '@videojs/icons/react/minimal';
+```
+
+### HTML
+
+Import icons as SVG strings for use in plain HTML or web components.
+
+```ts
+import { playIcon, pauseIcon } from '@videojs/icons/html';
+
+button.innerHTML = playIcon;
+```
+
+Import from a specific icon set:
+
+```ts
+import { playIcon } from '@videojs/icons/html/minimal';
+```
+
+## Styling
+
+All icons use `fill="currentColor"`, so they inherit the text color of their parent element. Size and color can be controlled with CSS:
+
+```css
+.icon {
+ width: 18px;
+ height: 18px;
+ color: white;
+}
+```
+
+Icons are designed on an **18×18 grid**. Rendering at `18px` (or exact multiples like `36px`) produces the crispest result. Other sizes may introduce sub-pixel rendering artifacts.
+
+## Adding Icons
+
+1. Add an SVG file to `src/assets//` (e.g., `src/assets/default/my-icon.svg`).
+2. Run `pnpm -F @videojs/icons build`.
+3. The build script optimizes the SVG with SVGO and generates React components and HTML string exports.
+
+The build automatically:
+
+- Removes hardcoded `fill`, `stroke`, `clip-rule`, and `fill-rule` attributes.
+- Adds `fill="currentColor"` for dynamic styling.
+- Preserves the `viewBox` attribute.
+- Generates `.js`, `.d.ts`, and `.tsx` files for each icon.
+
+## Adding an Icon Set
+
+Create a new directory under `src/assets/` (e.g., `src/assets/custom/`), add SVG files, and rebuild. The set is automatically available via:
+
+```ts
+import { PlayIcon } from '@videojs/icons/react/custom';
+import { playIcon } from '@videojs/icons/html/custom';
+```
+
+## Development
+
+Running `pnpm -F @videojs/icons dev` starts the build in watch mode. Any changes to `.svg` files under `src/assets/` trigger an automatic rebuild, including new icons and new icon sets — no restart required.
+
+## Scripts
+
+```bash
+pnpm -F @videojs/icons build # Build all icon sets
+pnpm -F @videojs/icons dev # Build and watch for changes
+pnpm -F @videojs/icons clean # Remove build output
+```
diff --git a/packages/react/src/presets/video/index.ts b/packages/react/src/presets/video/index.ts
index 93f7dd44..1b5f3ff1 100644
--- a/packages/react/src/presets/video/index.ts
+++ b/packages/react/src/presets/video/index.ts
@@ -1,3 +1,5 @@
export { Video, type VideoProps } from '@/media/video';
export * from './minimal-skin';
+export * from './minimal-skin.tailwind';
export * from './skin';
+export * from './skin.tailwind';
diff --git a/packages/react/src/presets/video/minimal-skin.css b/packages/react/src/presets/video/minimal-skin.css
index 40840be8..5de21ec4 100644
--- a/packages/react/src/presets/video/minimal-skin.css
+++ b/packages/react/src/presets/video/minimal-skin.css
@@ -70,27 +70,29 @@
.media-minimal-skin .media-overlay {
position: absolute;
inset: 0;
- display: flex;
- flex-flow: column nowrap;
- align-items: start;
pointer-events: none;
border-radius: inherit;
background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0));
- transition-property: opacity;
- transition-duration: 75ms;
- transition-delay: 0ms;
- transition-timing-function: ease-out;
-}
-.media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay {
+ backdrop-filter: saturate(1.5) brightness(0.9);
transition-delay: 500ms;
transition-duration: 500ms;
+ transition-behavior: allow-discrete;
+ transition-property: opacity, display;
+ transition-timing-function: ease-out;
opacity: 0;
+ display: none;
}
@media (prefers-reduced-motion: reduce) {
- .media-minimal-skin .media-controls:not([data-visible]) ~ .media-overlay {
+ .media-minimal-skin .media-overlay {
transition-duration: 100ms;
}
}
+.media-minimal-skin .media-controls[data-visible] ~ .media-overlay {
+ transition-duration: 50ms;
+ transition-delay: 0ms;
+ opacity: 1;
+ display: block;
+}
/* Media Control Bar UI/Styles */
.media-minimal-skin .media-controls {
@@ -126,7 +128,7 @@
scale: 1;
}
}
-@container media-root (width > 640px) {
+@container media-root (width > 40rem) {
.media-minimal-skin .media-controls {
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
gap: 0.875rem;
@@ -174,7 +176,7 @@
align-items: center;
gap: 0.075rem;
}
-@container media-root (width > 42rem) {
+@container media-root (width > 40rem) {
.media-minimal-skin .media-button-group {
gap: 0.125rem;
}
@@ -452,12 +454,12 @@
}
@container media-root (width > 48rem) {
.media-minimal-skin .media-captions {
- font-size: 2rem;
+ font-size: 1.875rem;
}
}
@container media-root (width > 80rem) {
.media-minimal-skin .media-captions {
- font-size: 2.5rem;
+ font-size: 2.25rem;
}
}
.media-minimal-skin .media-controls[data-visible] ~ .media-captions {
diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx
new file mode 100644
index 00000000..e1c0f026
--- /dev/null
+++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx
@@ -0,0 +1,301 @@
+import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
+import {
+ FullscreenEnterIcon,
+ FullscreenExitIcon,
+ PauseIcon,
+ PipIcon,
+ PlayIcon,
+ RestartIcon,
+ SeekIcon,
+ SpinnerIcon,
+ VolumeHighIcon,
+ VolumeLowIcon,
+ VolumeOffIcon,
+} from '@videojs/icons/react/minimal';
+import { cn } from '@videojs/utils/style';
+import { type ComponentProps, forwardRef, type ReactNode } from 'react';
+import { Container } from '@/player/context';
+import { BufferingIndicator } from '@/ui/buffering-indicator';
+import { Controls } from '@/ui/controls';
+import { FullscreenButton } from '@/ui/fullscreen-button';
+import { MuteButton } from '@/ui/mute-button';
+import { PiPButton } from '@/ui/pip-button';
+import { PlayButton } from '@/ui/play-button';
+import { SeekButton } from '@/ui/seek-button';
+import { Time } from '@/ui/time';
+import type { MinimalVideoSkinProps } from './minimal-skin';
+
+const SEEK_TIME = 10;
+
+/* ------------------------------------ Reused fragments ------------------------------------- */
+
+const icon = cn(
+ '[grid-area:1/1] size-4.5',
+ 'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
+ 'transition-discrete transition-[display,opacity] duration-150 ease-out'
+);
+
+const iconHidden = 'hidden opacity-0';
+
+/* --------------------------------------- Components ---------------------------------------- */
+
+const Button = forwardRef>(function Button({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
+ const { ended, paused } = state;
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
+ const { muted, volumeLevel } = state;
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
+ const { fullscreen } = state;
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/* ------------------------------------------ Skin ------------------------------------------- */
+
+export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode {
+ const { children, className, ...rest } = props;
+
+ return (
+ video]:block [&>video]:w-full [&>video]:h-full',
+ // Poster image
+ '[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full',
+ '[&>img]:object-cover [&>img]:pointer-events-none',
+ '[&>img]:transition-opacity [&>img]:duration-[250ms]',
+ '[&>img:not([data-visible])]:opacity-0',
+ // Fullscreen
+ '[&:fullscreen]:rounded-none',
+ className
+ )}
+ {...rest}
+ >
+ {children}
+
+
+ state.visible ? (
+
+
+
+ ) : null
+ }
+ />
+
+
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+ {/* Temporary spacer */}
+
+
+
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+
+
+ {/*
+
+
+ An example cue
+
+
+
*/}
+
+
+
+ );
+}
diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx
index 5305fcbc..044fa092 100644
--- a/packages/react/src/presets/video/minimal-skin.tsx
+++ b/packages/react/src/presets/video/minimal-skin.tsx
@@ -83,7 +83,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
/>
-
+
(
)}
/>
-
+
diff --git a/packages/react/src/presets/video/skin.css b/packages/react/src/presets/video/skin.css
index d80290b4..a1aff366 100644
--- a/packages/react/src/presets/video/skin.css
+++ b/packages/react/src/presets/video/skin.css
@@ -76,27 +76,29 @@
.media-default-skin .media-overlay {
position: absolute;
inset: 0;
- display: flex;
- flex-flow: column nowrap;
- align-items: start;
pointer-events: none;
border-radius: inherit;
background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0));
backdrop-filter: saturate(1.5) brightness(0.9);
- transition: opacity 0.15s ease-out;
- transition-duration: 100ms;
- transition-delay: 0ms;
-}
-.media-default-skin .media-controls:not([data-visible]) ~ .media-overlay {
transition-delay: 500ms;
transition-duration: 300ms;
+ transition-behavior: allow-discrete;
+ transition-property: opacity, display;
+ transition-timing-function: ease-out;
opacity: 0;
+ display: none;
}
@media (prefers-reduced-motion: reduce) {
- .media-default-skin .media-controls:not([data-visible]) ~ .media-overlay {
+ .media-default-skin .media-overlay {
transition-duration: 100ms;
}
}
+.media-default-skin .media-controls[data-visible] ~ .media-overlay {
+ transition-duration: 150ms;
+ transition-delay: 0ms;
+ opacity: 1;
+ display: block;
+}
/* Common Surface Styles - e.g. tooltips, popovers, controls */
.media-default-skin .media-surface {
@@ -161,7 +163,7 @@
scale: 1;
}
}
-@container media-root (width > 42rem) {
+@container media-root (width > 40rem) {
.media-default-skin .media-controls {
padding: 0.25rem;
gap: 0.125rem;
@@ -451,12 +453,12 @@
}
@container media-root (width > 48rem) {
.media-default-skin .media-captions {
- font-size: 2rem;
+ font-size: 1.875rem;
}
}
@container media-root (width > 80rem) {
.media-default-skin .media-captions {
- font-size: 2.5rem;
+ font-size: 2.25rem;
}
}
.media-default-skin .media-controls[data-visible] ~ .media-captions {
diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx
new file mode 100644
index 00000000..cd8869a9
--- /dev/null
+++ b/packages/react/src/presets/video/skin.tailwind.tsx
@@ -0,0 +1,307 @@
+import type { FullscreenButtonState, MuteButtonState, PlayButtonState } from '@videojs/core';
+import {
+ FullscreenEnterIcon,
+ FullscreenExitIcon,
+ PauseIcon,
+ PipIcon,
+ PlayIcon,
+ RestartIcon,
+ SeekIcon,
+ SpinnerIcon,
+ VolumeHighIcon,
+ VolumeLowIcon,
+ VolumeOffIcon,
+} from '@videojs/icons/react';
+import { cn } from '@videojs/utils/style';
+import { type ComponentProps, forwardRef, type ReactNode } from 'react';
+import { Container } from '@/player/context';
+import { BufferingIndicator } from '@/ui/buffering-indicator';
+import { Controls } from '@/ui/controls';
+import { FullscreenButton } from '@/ui/fullscreen-button';
+import { MuteButton } from '@/ui/mute-button';
+import { PiPButton } from '@/ui/pip-button';
+import { PlayButton } from '@/ui/play-button';
+import { SeekButton } from '@/ui/seek-button';
+import { Time } from '@/ui/time';
+import type { VideoSkinProps } from './skin';
+
+const SEEK_TIME = 10;
+
+/* ------------------------------------ Reused fragments ------------------------------------- */
+
+const surface = cn(
+ 'bg-white/10',
+ 'backdrop-blur-3xl backdrop-brightness-90 backdrop-saturate-150',
+ // Border and shadow
+ 'ring ring-white/5 ring-inset shadow-sm shadow-black/15',
+ // Border to enhance contrast on lighter videos
+ 'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
+ // Reduced transparency for users with preference
+ '[@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:ring-black [@media(prefers-reduced-transparency:reduce)]:after:ring-white/20',
+ // High contrast mode
+ 'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
+);
+
+const icon = cn(
+ '[grid-area:1/1] size-4.5 shrink-0',
+ 'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
+ 'transition-discrete transition-[display,opacity] duration-150 ease-out'
+);
+
+const iconHidden = 'hidden opacity-0';
+
+/* --------------------------------------- Components ---------------------------------------- */
+
+const Button = forwardRef>(function Button({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+function PlayButtonIcon({ state, className, ...rest }: { state: PlayButtonState } & ComponentProps<'svg'>) {
+ const { ended, paused } = state;
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function MuteButtonIcon({ state, className, ...rest }: { state: MuteButtonState } & ComponentProps<'svg'>) {
+ const { muted, volumeLevel } = state;
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function FullscreenButtonIcon({ state, className, ...rest }: { state: FullscreenButtonState } & ComponentProps<'svg'>) {
+ const { fullscreen } = state;
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/* ------------------------------------------ Skin ------------------------------------------- */
+
+export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
+ const { children, className, ...rest } = props;
+
+ return (
+ video]:block [&>video]:w-full [&>video]:h-full',
+ // Poster image
+ '[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full',
+ '[&>img]:object-cover [&>img]:pointer-events-none',
+ '[&>img]:transition-opacity [&>img]:duration-[250ms]',
+ '[&>img:not([data-visible])]:opacity-0',
+ // Fullscreen
+ '[&:fullscreen]:rounded-none',
+ className
+ )}
+ {...rest}
+ >
+ {children}
+
+
+ state.visible ? (
+
+ ) : null
+ }
+ />
+
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+
+
+ {/* Temporary spacer */}
+
+
+
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+
+ {/*
+
+
+ An example cue
+
+
+
*/}
+
+
+
+ );
+}