GODRICH

9 Custom Video Player CSS Controls, No Video File

A custom video player css setup replaces the browser's default control bar with your own markup and styles.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows what a person actually does with a player, not what is quickest to build. You press play (01). You drag to a moment you remember (02). You set the volume (03), then the pace (04), then switch on captions (05). Chapters (06) cut that same bar into named parts once the video runs long enough to browse. The last three change the frame instead of the picture: the player shrinks into a corner card (07), swells to fill the stage (08), and finally puts up its own keyboard cheat sheet (09). Behind every one of them is a fake stage — moving stripes, a sun, two hills, and a counter — because nine embedded <video> files would have cost more bandwidth than the entire rest of this site. Every animation here sticks to transform, opacity, and paint-only properties like color and border-radius, so nothing forces the browser into a fresh layout pass.

01Play/pause morph button

Press the button and the two upright bars spin out while a triangle spins in, and the clock beside them stops counting until you press it again. Two icons are stacked in the same box, each with its own rotated, shrunken resting pose, so only one is ever at full size. Across all 23 moving frames it changes 31.23% of the picture, which reads as one object turning rather than two images crossfading.

transform-originaria-labelripple
.pp__ic {
  position: absolute; inset: 0;
  display: inline-flex; align-items: center; justify-content: center;
  transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }

.pp__ring {
  position: absolute; inset: 0;
  border: 2px solid rgba(255, 255, 255, .8);
  border-radius: 50%;
  opacity: 0;
  transform: scale(1);
}

02Seek bar with time tooltip

Hover anywhere along the bar and a tooltip follows the cursor with the timestamp for that spot, plus a scene tile whose pattern shifts as you move. The blue played span, the gray buffered span, the thumb, and the tooltip are all derived from one custom property called --p, so they cannot drift apart. Registering --p through @property as a <number> is what lets keyframes interpolate it instead of flipping at the midpoint.

role=slideraria-valuenowhover
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }

.sk__buf, .sk__fill {
  position: absolute; inset: 0;
  border-radius: $r-pill;
  transform-origin: left center;
}
.sk__buf { background: rgba(23, 20, 26, .16); transform: scaleX(min(1, calc(var(--p) + .18))); }
.sk__fill { background: $color; transform: scaleX(var(--p)); }
.sk__thumbwrap {
  position: absolute; left: 0; right: 0; top: 0; bottom: 0;
  pointer-events: none;
  transform: translateX(calc((100% - 14px) * var(--p)));
}

03Volume slider popover

Click the speaker and a slider springs out to its right; drag it and the level really moves, with the icon stepping down to a quiet speaker and then to a crossed-out one at zero. The icon swap uses steps(1, end) so it cuts in a single frame rather than dissolving through a blurry half-state. The popover is placed absolutely beside the button, which is the only reason the speaker stays pinned to the bottom-left corner whether the slider is open or closed.

role=slidersteps(1,end)popover
.vol__pop {
  position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
  display: flex; align-items: center; gap: $sp-2;
  box-sizing: border-box;
  width: 152px; height: 36px;
  padding: 0 $sp-2;
  border-radius: $r-control;
  background: rgba(23, 20, 26, .88);
  transform: translateX(-6px) scale(.6);
  transform-origin: left bottom;
  opacity: 0;
  pointer-events: none;
  transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }

04Playback speed menu

The speed button opens a six-row list running from 0.5x to 2x, and the row you pick genuinely changes how fast the clock beside it counts. One tinted marker slides to the chosen row instead of six rows repainting, so exactly one element moves no matter how long the list grows. At 42.84% changed area it is the busiest of the first six items, because the whole panel scales in and out while the marker and the progress bar keep moving underneath.

aria-haspopupcheckEscape
.spd__menu {
  --i: 2;
  position: absolute; right: 0; bottom: calc(100% + 4px);
  box-sizing: border-box;
  width: 92px;
  overflow: hidden;
  border-radius: $r-control;
  background: rgba(23, 20, 26, .95);
  box-shadow: $shadow-press;
  opacity: 0;
  pointer-events: none;
  transform: translateY(6px) scale(.72);
  transform-origin: right bottom;
  transition: opacity $duration $easing, transform $duration $easing;
}
.pl.is-open .spd__menu { opacity: 1; pointer-events: auto; transform: none; }

.spd__mark {
  position: absolute; left: 0; right: 0; top: 0;
  height: $row;
  background: rgba(47, 109, 246, .85);
  transform: translateY(calc(#{$row} * var(--i)));
  transition: transform $duration $easing;
}

05Caption toggle and style

Each caption line rises from below as it takes over from the previous one, and a two-button picker swaps between a dark backing pill and outlined text with no pill at all. The CC button stores its state in aria-pressed, not only in a class name, so a screen reader announces the toggle the same way a sighted reader sees it. Notice the 21.99% and 22% pair in the keyframes below: that hard cut is a repair, and the section on what breaks explains why.

aria-pressedtranslateYopacity
@keyframes cap-1 {
  0%, 21.99% { opacity: 1; transform: none; }
  22%, 87.99% { opacity: 0; transform: translateY(10px); }
  88% { opacity: 1; transform: translateY(10px); }
  93%, 100% { opacity: 1; transform: none; }
}
@keyframes cap-2 {
  0%, 21.99% { opacity: 0; transform: translateY(10px); }
  22% { opacity: 1; transform: translateY(10px); }
  27%, 43.99% { opacity: 1; transform: none; }
  44%, 100% { opacity: 0; transform: translateY(10px); }
}

06Chapter marker seek bar

The single bar breaks into one tick per chapter, each filling on its own, and the name above changes the instant the playhead crosses a boundary. Every tick is positioned by percentage through left and right, so trimming the visual gap with margin-right never moves where a boundary truly falls. Clicking a tick jumps the playhead to the start of that chapter, and the arrow keys step five seconds at a time.

segmentaria-valuetextclick
.ch {
  --p: 0.0444;
  position: relative;
  flex: 1 1 auto;
  height: 12px;
}
.ch__seg {
  position: absolute; top: 0; bottom: 0;
  left: calc(var(--s) * 100%);
  right: calc(100% - var(--e) * 100%);
  margin-right: $sp-1;
  border-radius: $r-pill;
  background: rgba(23, 20, 26, .16);
  overflow: hidden;
}
.ch__seg--end { margin-right: 0; }
.ch__fill {
  position: absolute; inset: 0;
  display: block;
  border-radius: $r-pill;
  background: $color;
  transform-origin: left center;
  transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}

07Mini player (PiP-style)

The mini button shrinks the whole player into a small floating card at the bottom right and leaves a labeled placeholder where it used to sit. No second window opens: a single translate paired with scale performs the entire move, and the layout box never changes size. That is why 46.53% of the picture changes here without a single reflow, and why the close button or Escape can snap it straight back.

scalearia-labelfocus
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;

.pip.is-mini .pl { transform: translate($mini-x, $mini-y) scale($mini-scale); }
.pip.is-mini .pip__ph { opacity: 1; }
.pip.is-mini .pl__close { opacity: 1; pointer-events: auto; }
.pip.is-mini .pl__mini { opacity: 0; pointer-events: none; }

@keyframes pip-shrink {
  0%, 26% { transform: none; }
  38%, 70% { transform: translate($mini-x, $mini-y) scale($mini-scale); }
  86%, 100% { transform: none; }
}

08Fullscreen toggle

The expand button grows the player until it fills its stage, squares off the rounded corners on the way, and flips the icon to the compress arrows. Pressing it again or hitting Escape brings everything back. At 56.88% changed area this is the largest visual jump of the nine, and it is driven entirely by one scale factor plus a border-radius transition.

transformEscapearia-expanded
.fs__screen {
  position: relative;
  height: clamp(88px, 26vw, 124px);
  border-radius: $r-card;
  overflow: hidden;
  background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(23, 20, 26, .92));
  box-shadow: $shadow-raised;
  transition: border-radius $duration $easing;
}
.fs.is-full { transform: scale(1.3); }
.fs.is-full .fs__screen { border-radius: 0; }

09Keyboard shortcut overlay

The question-mark button dims the stage and floats a list of shortcuts over it, and Space, the left and right arrows, M, and F all genuinely work while it is open. Each key cap runs the same short keyframe, offset from its neighbors by a negative delay, which is why they light in sequence instead of together. With an intensity of 87.5 it is by far the liveliest of the nine, and it is also the only one that moves in 21 of 23 frames rather than all 23.

kbdrole=dialogfocus
.pl.is-demo .ovl kbd {
  animation-name: kbd-cycle;
  animation-duration: $dur-loop;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}
@keyframes kbd-cycle {
  0%, 10% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
  11%, 20% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; border-color: $stage-yellow; }
  21%, 100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
}
.pl.is-demo .ovl .k-space { animation-delay: -1980ms; }
.pl.is-demo .ovl .k-left  { animation-delay: -1700ms; }
.pl.is-demo .ovl .k-right { animation-delay: -1420ms; }
.pl.is-demo .ovl .k-m     { animation-delay: -1180ms; }
.pl.is-demo .ovl .k-f     { animation-delay: -940ms; }

Where it breaks — the trap

Three of these nine went out wrong on the first pass, and each failure came from the same root: a control that looks right standing still can still be wrong in motion.

The volume popover was the loudest failure. In its first build the speaker button and the popover were siblings in one flex row, which meant the popover reserved its full 152px of width even while closed. The speaker ended up floating near the middle of the frame instead of hugging the corner, and once opened, the popover lay across the "1:25 / 3:00" timecode. The repair was not a negative margin but taking the popover out of the flow completely with position: absolute; left: calc(100% + 4px), then moving the timecode over to the right edge. A closed popover now occupies no space at all, so the button sits exactly where a person expects to find it.

The seek bar broke more quietly. Its played span was written as a constant, transform: scaleX(.18), while only the thumb was wired to the animated value. Watching frame by frame, the playhead reached 54% of the track while the blue fill still sat at 20%, which is the sort of thing nobody consciously notices and everybody feels as cheapness. Deriving all four pieces from --p fixed it, but that alone was not enough: without registering the property, a custom property in keyframes does not interpolate at all. It holds its old value and then jumps to the new one halfway through, so the bar teleports. The @property rule at the top of that snippet is the whole difference between a bar that slides and a bar that snaps.

The caption swap produced the strangest bug of the set. The outgoing line faded out at 25% of the loop and the incoming line started rising at 25%, which looks perfectly reasonable in a code review. The capture takes 24 frames over 2 seconds, so samples land every 4.1667%, and the seventh sample lands on exactly 25.0% — the one instant when neither line was on screen. The recorded clip had a caption player with no caption in it. Narrowing the handoff to a 0.01% span, from 21.99% to 22%, means the opacity change can never be caught mid-flight, while the translateY entrance still glides. For the same family of reasons, the chapter clock in 06 had to stop counting with setInterval and start reading --p through getComputedStyle inside requestAnimationFrame, because a separately ticking timer once read 2:49 while the playhead sat in the first chapter. The one trap with no workaround is fullscreen: item 08 never calls requestFullscreen(), because an iframe silently refuses the request unless its host adds allow="fullscreen", so the demo fakes the move with transform: scale(1.3) inside its own stage and leaves the real API as a one-line swap for your page. The full source for all nine, including every keyframe trimmed out of the snippets above, is in an archive locked with 3ekbtjj6, and dropping the unpacked folder next to your markup is all the setup there is. One last measured note before you open it: a box grown with scale() leaves the layout alone but still swells the document's scroll height, even under overflow: hidden, which bit both 07 and 08 at a 320px-wide viewport until the stages were given explicit room.

Accessibility — what survives reduced-motion

Every one of the nine ships a @media (prefers-reduced-motion: reduce) block, and each block switches off decorative loops only. The panning stripes stop, the self-running demo stops, and the state values stay exactly as they were: pressed stays pressed, open stays open, the volume keeps its number, and the chapter keeps its name. A reader who has motion turned off still gets a working control, not a frozen picture of one.

The roles were chosen per part rather than sprinkled evenly. Items 02, 03, and 06 are marked with the ARIA slider role and carry aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext; it is that last one that makes 06 announce "Setup chapter, 0:24" instead of the bare number 24, which on its own would mean nothing. Items 05 and 07 use aria-pressed because they are toggles, while 01 swaps its aria-label between Play and Pause instead. Item 04 is a real menu: aria-haspopup="menu" and aria-expanded on the trigger, role="menuitemradio" with aria-checked on the rows. Item 08 uses aria-expanded, and 09 is a role="dialog" with aria-modal="true".

Nothing in the set needs a mouse: 02, 03, and 06 answer the arrow keys plus Home and End, 04 walks its rows with the arrow keys and commits with Enter or Space, 09 answers Space, the arrows, M, and F, and Escape closes whatever is open in 04, 07, 08, and 09. Every focus return in the set is written as focus({ preventScroll: true }), because these parts get embedded in iframes on a listing page and a plain focus() would yank the reader's scroll position down to whichever demo just took focus. More parts that carry moving pictures and sound live in the media category, and the ones that answer a press are gathered in the click category.

FAQ

Can I use these without any video file at all?

Yes, and that is what the demos are doing. The stage behind each control is CSS: a repeating gradient panning sideways for the picture, a circle for the sun, two rounded shapes for hills, and a counter for the timecode. Swap that block for a real <video> element and wire the same class toggles to its play(), pause(), currentTime, and volume, and the controls keep behaving identically.

Why build controls instead of using the browser's own?

The native control bar is competent and free, so use it when you only need play, seek, and volume. Build your own when the bar has to carry chapters, a speed menu, a caption-style picker, or your brand's shape and color, or when the control has to look identical in Chrome, Safari, and Firefox — which the native bars never will. The cost is that you inherit the whole job, keyboard and roles included.

How do I change the animation speed of all nine at once?

Each stylesheet declares two locals at the top, $duration and $easing, and both alias tokens from the shared file, which also supplies the $dur-loop every self-running loop runs on. Retime $dur-quick, $dur-base, and $dur-loop there and all nine follow; the only hardcoded milliseconds left anywhere are the negative animation-delay values that stagger the key caps in 09. The React versions in the archive take duration, easing, and color as props and pass them down as custom properties.

Enter the archive password

The password is inside this article. You will find it as you read.