VideoPlayer
Play, pause, and scrub branded video with DS chrome. Product demos and embedded media.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Embedded product demo:
```tsx import { VideoPlayer } from '@/components/atoms/VideoPlayer';
<VideoPlayer videoId="abc123" muted autoplay={false} placeholder="/poster.jpg" /> ```
## Accesibilidad
- `videoId` is required. Do not autoplay with sound; `autoplay` implies muted via data attributes. - Provide a real poster/placeholder and ensure controls remain operable when motion hooks are installed.
## Cuándo no usar
- Decorative looping backgrounds without controls → lighter media patterns, not a full player chrome. - Audio-only content → dedicated audio UI, not `VideoPlayer`.
## Criterio de uso
- Es un controlador de Cloudflare Stream: necesita `videoId` y `customer`, no una URL de archivo. - `autoplay` solo es aceptable con `muted`; ademas, reduced-motion desactiva el autoplay y deja el player operativo. - Da siempre un `placeholder`: el primer frame en blanco se lee como error de carga.
## Gotchas
- El autoplay es una preferencia, no una garantia: politicas del navegador pueden bloquearlo y el player debe seguir usable. - Es runtime-only: fuera del canal de paste, se consume via React.
Uso
import { VideoPlayer } from '@/components/atoms/VideoPlayer';
<VideoPlayer videoId="abc123" muted autoplay={false} placeholder="/poster.jpg" />Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| autoplay | boolean | false | `true` / `false` | Starts playback when the player activates. | false by default. true only with muted (autoplay policies); sets data-video-muted when autoplay is on. |
| muted | boolean | false | `true` / `false` | Starts muted. | true for autoplay heroes; false when sound is intentional. Default: false. |
Gotchas
- react
videoId is required (provider id). customer/placeholder optional. Behavior is driven by data-video-* attributes + motion/controller hooks.
- a11y
Provide a real title/placeholder for the media region; do not autoplay with sound.
Anatomía CSS
<div class="video-player"> <span class="video-player__before"></span> <span class="video-player__btn"></span> <span class="video-player__btn-pause-svg"></span> <span class="video-player__btn-play-svg"></span> <span class="video-player__dark"></span> <span class="video-player__duration"></span> <span class="video-player__duration-span"></span> <span class="video-player__fullscreen"></span> <span class="video-player__fullscreen-scale-svg"></span> <span class="video-player__fullscreen-shrink-svg"></span> <span class="video-player__iframe"></span> <span class="video-player__interface"></span> <span class="video-player__interface-bottom"></span> <span class="video-player__loading"></span> <span class="video-player__loading-svg"></span> <span class="video-player__mute"></span> <span class="video-player__pause"></span> <span class="video-player__placeholder"></span> <span class="video-player__play"></span> <span class="video-player__timeline"></span> <span class="video-player__timeline-input"></span> <span class="video-player__timeline-progress"></span> <span class="video-player__volume-mute-svg"></span> <span class="video-player__volume-up-svg"></span> </div>
| Clase | Propósito |
|---|---|
video-player | root |
video-player__before | element |
video-player__btn | element |
video-player__btn-pause-svg | element |
video-player__btn-play-svg | element |
video-player__dark | element |
video-player__duration | element |
video-player__duration-span | element |
video-player__fullscreen | element |
video-player__fullscreen-scale-svg | element |
video-player__fullscreen-shrink-svg | element |
video-player__iframe | element |
video-player__interface | element |
video-player__interface-bottom | element |
video-player__loading | element |
video-player__loading-svg | element |
video-player__mute | element |
video-player__pause | element |
video-player__placeholder | element |
video-player__play | element |
video-player__timeline | element |
video-player__timeline-input | element |
video-player__timeline-progress | element |
video-player__volume-mute-svg | element |
video-player__volume-up-svg | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | var(--player-progress-bg) | --player-progress-bg |
| all | fg | transparent | (literal) |
| all | border | 0 | (unparsed) |
| all | focused-bg | transparent | (literal) |
| all | focused-fg | transparent | (literal) |
| all | focused-border | transparent | (literal) |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
opacity | var(--duration-300) | linear |
opacity | var(--duration-300) | linear |
opacity | var(--duration-300) | linear |
opacity | var(--duration-300) | linear |
opacity | var(--duration-300) | linear |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Video Player
Custom video player with glass UI controls. Cloudflare Stream.
State driven by data attributes (framework-agnostic).
Tokens: --surface-glass, --surface-glass-blur, --surface-dark,
--surface-dark-overlay, --player-accent, --player-progress-bg,
--player-foreground
Parts: .video-player, .video-player__iframe, .video-player__placeholder,
.video-player__dark, .video-player__play, .video-player__pause,
.video-player__btn, .video-player__interface,
.video-player__timeline, .video-player__mute,
.video-player__fullscreen, .video-player__duration,
.video-player__loading
------------------------------------------------------------------------- */
/* ---- Container ---- */
.video-player {
pointer-events: auto;
color: var(--player-foreground);
isolation: isolate;
background-color: var(--surface-dark);
border-radius: 16px;
justify-content: center;
align-items: center;
width: 100%;
display: flex;
position: relative;
overflow: hidden;
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
font-size: 12.8px;
}
/* Cover mode */
.video-player[data-video-update-size="cover"] {
width: 100%;
height: 100%;
}
/* ---- Aspect ratio spacer ---- */
.video-player__before {
padding-top: 62.5%;
}
/* ---- Iframe ---- */
.video-player__iframe {
pointer-events: none;
width: 100%;
height: 100%;
position: absolute;
border: none;
}
/* ---- Placeholder ---- */
.video-player__placeholder {
object-fit: cover;
width: 100%;
height: 100%;
display: block;
position: absolute;
transition: opacity 300ms linear;
}
.video-player[data-video-activated="true"][data-video-loaded="true"] .video-player__placeholder {
opacity: 0;
}
/* ---- Dark overlay ---- */
.video-player__dark {
pointer-events: none;
background-color: var(--surface-dark);
width: 100%;
height: 100%;
position: absolute;
transition: opacity 300ms linear;
opacity: 0.5;
}
.video-player[data-video-playing="false"] .video-player__dark {
opacity: 0.3;
}
.video-player[data-video-activated="false"][data-video-playing="false"] .video-player__dark {
opacity: 0;
}
.video-player[data-video-activated="true"][data-video-loaded="true"] .video-player__dark {
opacity: 0;
}
@media (hover: hover) and (pointer: fine) {
.video-player[data-video-hover="true"]:hover .video-player__dark {
opacity: 0.3 !important;
}
}
/* ---- Play / Pause buttons ---- */
.video-player__play,
.video-player__pause {
cursor: pointer;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
}
/* Glass button */
.video-player__btn {
-webkit-backdrop-filter: blur(var(--surface-glass-blur));
backdrop-filter: blur(var(--surface-glass-blur));
background-color: var(--surface-glass);
border-radius: 9999px;
justify-content: center;
align-items: center;
width: 6em;
height: 6em;
display: flex;
position: relative;
transition: opacity 300ms linear;
}
.video-player__btn-play-svg { width: 40%; }
.video-player__btn-pause-svg { width: 50%; }
/* Pause: hidden by default, show when playing */
.video-player .video-player__pause { display: none; }
.video-player[data-video-playing="true"] .video-player__pause { display: flex; }
.video-player .video-player__pause .video-player__btn { opacity: 0; }
.video-player[data-video-activated="true"][data-video-playing="false"] .video-player__pause .video-player__btn,
.video-player[data-video-activated="true"][data-video-hover="true"]:hover .video-player__pause .video-player__btn {
opacity: 1;
}
@media (hover: none) and (pointer: coarse) {
.video-player[data-video-activated="true"][data-video-playing="true"] .video-player__pause .video-player__btn {
opacity: 0 !important;
}
}
/* Play: hidden when playing */
.video-player[data-video-playing="true"] .video-player__play { opacity: 0; }
/* ---- Loading ---- */
.video-player__loading {
pointer-events: none;
color: var(--player-accent);
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: absolute;
display: flex;
opacity: 0;
transition: opacity 300ms linear;
}
.video-player__loading-svg { width: 6em; }
.video-player[data-video-playing="true"] .video-player__loading { opacity: 1; }
.video-player[data-video-playing="true"][data-video-loaded="true"] .video-player__loading { opacity: 0; }
/* ---- Interface (controls bar) ---- */
.video-player .video-player__interface { opacity: 0; }
.video-player[data-video-activated="false"][data-video-playing="false"] .video-player__interface { opacity: 1; }
.video-player[data-video-activated="true"][data-video-playing="false"] .video-player__interface,
.video-player[data-video-activated="true"][data-video-hover="true"]:hover .video-player__interface {
opacity: 1;
}
@media (hover: none) and (pointer: coarse) {
.video-player[data-video-activated="true"][data-video-playing="true"] .video-player__interface {
opacity: 0 !important;
}
}
.video-player__interface {
pointer-events: none;
flex-flow: column;
justify-content: flex-end;
align-items: stretch;
width: 100%;
height: 100%;
padding: 16px;
transition: opacity 300ms linear;
display: flex;
position: absolute;
}
.video-player__interface * { pointer-events: all; }
.video-player__interface-bottom {
display: flex;
align-items: center;
gap: 16px;
}
/* ---- Timeline ---- */
.video-player__timeline {
flex-grow: 1;
justify-content: center;
align-items: center;
height: 16px;
display: flex;
position: relative;
}
.video-player__timeline-input {
pointer-events: auto;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background-color: transparent;
width: 100%;
height: 100%;
display: block;
position: relative;
}
.video-player__timeline-progress {
-webkit-appearance: none;
appearance: none;
height: 2px;
border-radius: 9999px;
color: var(--player-accent);
background-color: transparent;
border: none;
width: 100%;
margin: 0;
padding: 0;
position: absolute;
left: 0;
overflow: hidden;
}
.video-player progress::-webkit-progress-bar {
border-radius: 9999px;
background-color: var(--player-progress-bg);
}
.video-player progress::-webkit-progress-value {
background: var(--player-accent);
}
.video-player progress::-moz-progress-bar {
border-radius: 9999px;
background: var(--player-accent);
}
/* Range thumb */
.video-player [type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 8px;
width: 8px;
border-radius: 9999px;
background-color: var(--player-accent);
cursor: pointer;
margin-top: calc((2px / 2) - (8px / 2));
}
.video-player [type="range"]::-moz-range-thumb {
border: 0;
height: 8px;
width: 8px;
border-radius: 9999px;
background: var(--player-accent);
cursor: pointer;
}
.video-player [type="range"]::-webkit-slider-runnable-track,
.video-player [type="range"]:focus::-webkit-slider-runnable-track {
background-color: transparent;
border-color: transparent;
color: transparent;
}
.video-player [type="range"]::-moz-range-track {
height: 2px;
border-radius: 9999px;
background: var(--player-progress-bg);
border: 0;
}
.video-player [type="range"]:focus { outline: none; }
/* ---- Mute ---- */
.video-player__mute {
cursor: pointer;
flex-shrink: 0;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
display: flex;
position: relative;
}
.video-player__volume-up-svg,
.video-player__volume-mute-svg {
width: 100%;
position: absolute;
}
.video-player .video-player__mute svg:nth-child(2),
.video-player[data-video-muted="true"] .video-player__mute svg:nth-child(1) { display: none; }
.video-player .video-player__mute svg:nth-child(1),
.video-player[data-video-muted="true"] .video-player__mute svg:nth-child(2) { display: block; }
/* ---- Fullscreen ---- */
.video-player__fullscreen {
cursor: pointer;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
display: flex;
position: relative;
}
.video-player__fullscreen-scale-svg,
.video-player__fullscreen-shrink-svg {
width: 100%;
position: absolute;
}
.video-player .video-player__fullscreen svg:nth-child(2),
.video-player[data-video-fullscreen="true"] .video-player__fullscreen svg:nth-child(1) { display: none; }
.video-player .video-player__fullscreen svg:nth-child(1),
.video-player[data-video-fullscreen="true"] .video-player__fullscreen svg:nth-child(2) { display: block; }
/* ---- Duration ---- */
.video-player__duration {
flex-shrink: 0;
}
.video-player__duration-span {
text-align: center;
white-space: nowrap;
user-select: none;
font-size: 12.8px;
font-variant-numeric: tabular-nums;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.video-player__placeholder,
.video-player__dark,
.video-player__btn,
.video-player__loading,
.video-player__interface {
transition-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Icons ---- */ const PlayIcon = () => ( <svg className="video-player__btn-play-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path fillRule="evenodd" clipRule="evenodd" d="M6.38178 6.13982C6.40762 5.94017 6.48425 5.74878 6.60555 5.58089C6.72686 5.413 6.88949 5.27326 7.08049 5.17279C7.2715 5.07232 7.48559 5.01391 7.70574 5.00219C7.92587 4.99048 8.14597 5.02579 8.3485 5.10532C9.37235 5.50436 11.6669 6.45272 14.5784 7.98469C17.4908 9.51754 19.5395 10.8562 20.4294 11.4635C21.1891 11.9829 21.191 13.013 20.4304 13.5342C19.5491 14.1381 17.5256 15.4591 14.5784 17.0113C11.6283 18.5635 9.36078 19.5005 8.34657 19.8942C7.47311 20.2343 6.49554 19.7183 6.38178 18.8597C6.24873 17.856 6 15.5769 6 12.4989C6 9.42262 6.24777 7.14443 6.38178 6.13982Z" fill="currentColor" /> </svg> ); const PauseIcon = () => ( <svg className="video-player__btn-pause-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M8 6.5C8 6.22386 8.22386 6 8.5 6H10.5C10.7761 6 11 6.22386 11 6.5V17.5C11 17.7761 10.7761 18 10.5 18H8.5C8.22386 18 8 17.7761 8 17.5V6.5Z" fill="currentColor" /> <path d="M14 6.5C14 6.22386 14.2239 6 14.5 6H16.5C16.7761 6 17 6.22386 17 6.5V17.5C17 17.7761 16.7761 18 16.5 18H14.5C14.2239 18 14 17.7761 14 17.5V6.5Z" fill="currentColor" /> </svg> ); const VolumeUpIcon = () => ( <svg className="video-player__volume-up-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M3 8.99998V15H7L12 20V3.99998L7 8.99998H3ZM16.5 12C16.5 10.23 15.48 8.70998 14 7.96998V16.02C15.48 15.29 16.5 13.77 16.5 12ZM14 3.22998V5.28998C16.89 6.14998 19 8.82998 19 12C19 15.17 16.89 17.85 14 18.71V20.77C18.01 19.86 21 16.28 21 12C21 7.71998 18.01 4.13998 14 3.22998Z" fill="currentColor" /> </svg> ); const VolumeMuteIcon = () => ( <svg className="video-player__volume-mute-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M16.5 12C16.5 10.23 15.48 8.71 14 7.97V10.18L16.45 12.63C16.48 12.43 16.5 12.22 16.5 12ZM19 12C19 12.94 18.8 13.82 18.46 14.64L19.97 16.15C20.63 14.91 21 13.5 21 12C21 7.72 18.01 4.14 14 3.23V5.29C16.89 6.15 19 8.83 19 12ZM4.27 3L3 4.27L7.73 9H3V15H7L12 20V13.27L16.25 17.52C15.58 18.04 14.83 18.45 14 18.7V20.76C15.38 20.45 16.63 19.81 17.69 18.95L19.73 21L21 19.73L12 10.73L4.27 3ZM12 4L9.91 6.09L12 8.18V4Z" fill="currentColor" /> </svg> ); const FullscreenScaleIcon = () => ( <svg className="video-player__fullscreen-scale-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <rect x="3" y="14" width="2" height="7" fill="currentColor" /><rect x="3" y="3" width="2" height="7" fill="currentColor" /> <rect x="19" y="3" width="2" height="7" fill="currentColor" /><rect x="19" y="14" width="2" height="7" fill="currentColor" /> <rect x="3" y="19" width="7" height="2" fill="currentColor" /><rect x="14" y="19" width="7" height="2" fill="currentColor" /> <rect x="3" y="3" width="7" height="2" fill="currentColor" /><rect x="14" y="3" width="7" height="2" fill="currentColor" /> </svg> ); const FullscreenShrinkIcon = () => ( <svg className="video-player__fullscreen-shrink-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <rect x="7" y="2" width="2" height="7" fill="currentColor" /><rect x="15" y="2" width="2" height="7" fill="currentColor" /> <rect x="15" y="15" width="2" height="7" fill="currentColor" /><rect x="8" y="15" width="2" height="7" fill="currentColor" /> <rect x="2" y="7" width="7" height="2" fill="currentColor" /><rect x="3" y="15" width="7" height="2" fill="currentColor" /> <rect x="15" y="7" width="7" height="2" fill="currentColor" /><rect x="15" y="15" width="7" height="2" fill="currentColor" /> </svg> ); const LoadingSpinner = () => ( <svg className="video-player__loading-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <path fill="currentColor" d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50"> <animateTransform attributeName="transform" type="rotate" dur="1s" from="0 50 50" to="360 50 50" repeatCount="indefinite" /> </path> </svg> ); /* ---- Component ---- */ export type VideoPlayerProps = { videoId: string; customer?: string; autoplay?: boolean; muted?: boolean; placeholder?: string; className?: string; }; export function VideoPlayer({ videoId, customer, autoplay = false, muted = false, placeholder, className, }: VideoPlayerProps) { return ( <div className={cn('video-player', className)} data-video-player-init="" data-video-id={videoId} {...(customer ? { 'data-video-customer': customer } : {})} data-video-autoplay={String(autoplay)} data-video-playing="false" data-video-activated="false" data-video-fullscreen="false" data-video-paused-by-user="false" data-video-hover="false" data-video-loaded="false" data-video-muted={String(muted || autoplay)} > <div className="video-player__before" /> <iframe width="640" height="360" className="video-player__iframe" allowFullScreen allow="autoplay; encrypted-media" /> {placeholder && ( <img src={placeholder} loading="eager" alt="" className="video-player__placeholder" /> )} <div className="video-player__dark" /> <div className="video-player__play" data-video-control="play"> <div className="video-player__btn"><PlayIcon /></div> </div> <div className="video-player__pause" data-video-control="pause"> <div className="video-player__btn"><PauseIcon /></div> </div> <div className="video-player__interface"> <div className="video-player__interface-bottom"> <div className="video-player__duration"> <span className="video-player__duration-span" data-video-duration="">0:00</span> </div> <div className="video-player__timeline"> <progress className="video-player__timeline-progress" max={100} value={0} /> <input className="video-player__timeline-input" type="range" min={0} max={100} step={0.01} data-video-control="timeline" defaultValue={0} /> </div> <div className="video-player__mute" data-video-control="mute"> <VolumeUpIcon /> <VolumeMuteIcon /> </div> <div className="video-player__fullscreen" data-video-control="fullscreen"> <FullscreenScaleIcon /> <FullscreenShrinkIcon /> </div> </div> </div> <div className="video-player__loading"><LoadingSpinner /></div> </div> ); }