From ac346dbbd68443558ae16e4c81a28c8c605ae687 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 27 Feb 2026 12:52:30 +1100 Subject: [PATCH] feat(react): support native caption track shifting in video skins (#636) --- .../react/src/presets/video/minimal-skin.css | 39 +++++++++++++------ .../presets/video/minimal-skin.tailwind.tsx | 20 +++++++++- .../react/src/presets/video/minimal-skin.tsx | 4 +- packages/react/src/presets/video/skin.css | 39 +++++++++++++------ .../react/src/presets/video/skin.tailwind.tsx | 20 +++++++++- packages/react/src/presets/video/skin.tsx | 4 +- 6 files changed, 96 insertions(+), 30 deletions(-) diff --git a/packages/react/src/presets/video/minimal-skin.css b/packages/react/src/presets/video/minimal-skin.css index 83680a21..bf7e4140 100644 --- a/packages/react/src/presets/video/minimal-skin.css +++ b/packages/react/src/presets/video/minimal-skin.css @@ -573,16 +573,8 @@ z-index: 20; font-size: 1rem; text-wrap: balance; - /* The delay must account for the controls delay/duration */ - transition: transform 150ms ease-out; - transition-delay: 600ms; pointer-events: none; } -@media (prefers-reduced-motion: reduce) { - .media-minimal-skin .media-captions { - transition-duration: 50ms; - } -} .media-minimal-skin .media-captions__container { display: flex; flex-direction: column; @@ -630,8 +622,33 @@ } } -/* Shift captions up when controls visible */ +/* Caption shifting styles (custom and native) */ +.media-minimal-skin { + --media-caption-track-delay: 600ms; + --media-caption-track-y: -0.5rem; +} +.media-minimal-skin:has(.media-controls[data-visible]) { + --media-caption-track-delay: 25ms; + --media-caption-track-y: -3rem; +} +.media-minimal-skin .media-captions, +.media-minimal-skin video::-webkit-media-text-track-container { + /* NOTE: The delay must account for the controls delay/duration */ + transition: transform 150ms ease-out; + transition-delay: var(--media-caption-track-delay); +} +.media-minimal-skin video::-webkit-media-text-track-container { + transform: translateY(var(--media-caption-track-y)) scale(0.98); + z-index: 1; + font-family: inherit; +} +/* When controls are visible, shift captions up to avoid overlap */ .media-minimal-skin .media-controls[data-visible] ~ .media-captions { - transform: translateY(-2.5rem); - transition-delay: 25ms; + transform: translateY(calc(var(--media-caption-track-y) - 0.5rem)); +} +@media (prefers-reduced-motion: reduce) { + .media-minimal-skin .media-captions, + .media-minimal-skin video::-webkit-media-text-track-container { + transition-duration: 50ms; + } } diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 9b4e87df..24fe3753 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -149,14 +149,27 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod '[&>img]:object-cover [&>img]:pointer-events-none', '[&>img]:transition-opacity [&>img]:duration-[250ms]', '[&>img:not([data-visible])]:opacity-0', + // Caption track CSS variables (consumed by native track container below) + '[--media-caption-track-delay:600ms]', + '[--media-caption-track-y:-0.5rem]', + 'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]', + 'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]', + // Native caption track container + '[&_video::-webkit-media-text-track-container]:transition-transform', + '[&_video::-webkit-media-text-track-container]:duration-150', + '[&_video::-webkit-media-text-track-container]:ease-out', + '[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)', + '[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)', + '[&_video::-webkit-media-text-track-container]:scale-98', + '[&_video::-webkit-media-text-track-container]:z-1', + '[&_video::-webkit-media-text-track-container]:font-[inherit]', + 'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50', // Fullscreen '[&:fullscreen]:rounded-none', className )} {...rest} > - {children} - state.visible ? ( @@ -203,6 +216,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod /> + + {children} ); } diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index 8f6f7dc7..e9db4049 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -84,8 +84,6 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { return ( - {children} - state.visible ? ( @@ -209,6 +207,8 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { */}
+ + {children} ); } diff --git a/packages/react/src/presets/video/skin.css b/packages/react/src/presets/video/skin.css index 349f1a9e..80961ff4 100644 --- a/packages/react/src/presets/video/skin.css +++ b/packages/react/src/presets/video/skin.css @@ -574,16 +574,8 @@ z-index: 20; font-size: 1rem; text-wrap: balance; - /* The delay must account for the controls delay/duration */ - transition: transform 150ms ease-out; - transition-delay: 600ms; pointer-events: none; } -@media (prefers-reduced-motion: reduce) { - .media-default-skin .media-captions { - transition-duration: 50ms; - } -} .media-default-skin .media-captions__container { display: flex; flex-direction: column; @@ -631,8 +623,33 @@ } } -/* Shift captions up when controls visible */ +/* Caption shifting styles (custom and native) */ +.media-default-skin { + --media-caption-track-delay: 600ms; + --media-caption-track-y: -0.5rem; +} +.media-default-skin:has(.media-controls[data-visible]) { + --media-caption-track-delay: 25ms; + --media-caption-track-y: -3.5rem; +} +.media-default-skin .media-captions, +.media-default-skin video::-webkit-media-text-track-container { + /* NOTE: The delay must account for the controls delay/duration */ + transition: transform 150ms ease-out; + transition-delay: var(--media-caption-track-delay); +} +.media-default-skin video::-webkit-media-text-track-container { + transform: translateY(var(--media-caption-track-y)) scale(0.98); + z-index: 1; + font-family: inherit; +} +/* When controls are visible, shift captions up to avoid overlap */ .media-default-skin .media-controls[data-visible] ~ .media-captions { - transform: translateY(-3rem); - transition-delay: 25ms; + transform: translateY(calc(var(--media-caption-track-y) - 0.5rem)); +} +@media (prefers-reduced-motion: reduce) { + .media-default-skin .media-captions, + .media-default-skin video::-webkit-media-text-track-container { + transition-duration: 50ms; + } } diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 14bd98c4..073692ac 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -165,14 +165,27 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { '[&>img]:object-cover [&>img]:pointer-events-none', '[&>img]:transition-opacity [&>img]:duration-[250ms]', '[&>img:not([data-visible])]:opacity-0', + // Caption track CSS variables (consumed by native track container below) + '[--media-caption-track-delay:600ms]', + '[--media-caption-track-y:-0.5rem]', + 'has-[[data-controls][data-visible]]:[--media-caption-track-delay:25ms]', + 'has-[[data-controls][data-visible]]:[--media-caption-track-y:-3.5rem]', + // Native caption track container + '[&_video::-webkit-media-text-track-container]:transition-transform', + '[&_video::-webkit-media-text-track-container]:duration-150', + '[&_video::-webkit-media-text-track-container]:ease-out', + '[&_video::-webkit-media-text-track-container]:delay-(--media-caption-track-delay)', + '[&_video::-webkit-media-text-track-container]:translate-y-(--media-caption-track-y)', + '[&_video::-webkit-media-text-track-container]:scale-98', + '[&_video::-webkit-media-text-track-container]:z-1', + '[&_video::-webkit-media-text-track-container]:font-[inherit]', + 'motion-reduce:[&_video::-webkit-media-text-track-container]:duration-50', // Fullscreen '[&:fullscreen]:rounded-none', className )} {...rest} > - {children} - state.visible ? ( @@ -223,6 +236,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { /> + + {children} ); } diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 3cf9b183..f479c43b 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -84,8 +84,6 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { return ( - {children} - state.visible ? ( @@ -203,6 +201,8 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
*/}
+ + {children} ); }