Skip to content

COMPONENT

Clip

A short, silent, looping video where a picture would go. Think of it as a picture that moves, not as a video player.

A clip, with the corner control

Scroll it out of view and back: it stops and starts. Press pause and do the same β€” it stays paused, because you asked.

HTML
<div class="frame clip" x-ds-clip
     data-label-play="Play the clip" data-label-pause="Pause the clip">
  <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
    <source src="/clips/grid.webm" type="video/webm" />
    <source src="/clips/grid.mp4" type="video/mp4" />
  </video>
  <button class="clip-toggle" type="button" hidden></button>
</div>

The same clip, with the plate

The control as a deliberate part of the composition rather than an apology for it. Its fill and its ink are one pairing β€” reassign both or neither.

HTML
<div class="frame clip" x-ds-clip
     data-label-play="Play the clip" data-label-pause="Pause the clip">
  <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
    <source src="/clips/grid.webm" type="video/webm" />
    <source src="/clips/grid.mp4" type="video/mp4" />
  </video>
  <button class="clip-toggle clip-toggle-plate" type="button" hidden></button>
</div>

The same clip, with the control hanging off it

A tab on the edge rather than a button on the picture. Note the markup: `clip` has moved off the frame and onto a wrapper, and the button is the frame's sibling. That is not a preference β€” see the note below.

HTML
<div class="clip clip-hang" x-ds-clip
     data-label-play="Play the clip" data-label-pause="Pause the clip">
  <div class="frame">
    <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
      <source src="/clips/grid.webm" type="video/webm" />
      <source src="/clips/grid.mp4" type="video/mp4" />
    </video>
  </div>
  <button class="clip-toggle clip-toggle-hang" type="button" hidden></button>
</div>

The other three corners

Add `clip-toggle-hang-start` to move it along the edge, `clip-toggle-hang-above` to flip it to the top. The base is the ordinary case, so there is no `-below` and no `-end`.

HTML
<div class="split" style="--split-min:200px">
  <div class="clip clip-hang" x-ds-clip data-label-play="Play" data-label-pause="Pause">
    <div class="frame">
      <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
        <source src="/clips/grid.webm" type="video/webm" />
        <source src="/clips/grid.mp4" type="video/mp4" />
      </video>
    </div>
    <button class="clip-toggle clip-toggle-hang clip-toggle-hang-start" type="button" hidden></button>
  </div>

  <div class="clip clip-hang" x-ds-clip data-label-play="Play" data-label-pause="Pause">
    <div class="frame">
      <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
        <source src="/clips/grid.webm" type="video/webm" />
        <source src="/clips/grid.mp4" type="video/mp4" />
      </video>
    </div>
    <button class="clip-toggle clip-toggle-hang clip-toggle-hang-above" type="button" hidden></button>
  </div>
</div>

One tab for a whole wall of them

Three clips, no buttons on the pictures, and one pause hanging off the mesh. `x-ds-motion` goes on the wrapper where `x-ds-clip` went; the clips underneath go bare. Press it and all three stop.

HTML
<div class="clip clip-hang clip-hang-panel" x-ds-motion
     data-label-play="Play the clips" data-label-pause="Pause the clips">
  <div class="hairline-grid" style="--cell:170px">
    <div class="hairline-cell hairline-cell-flush">
      <div class="frame frame-bare" x-ds-clip>
        <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
          <source src="/clips/grid.webm" type="video/webm" />
          <source src="/clips/grid.mp4" type="video/mp4" />
        </video>
      </div>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <div class="frame frame-bare" x-ds-clip>
        <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
          <source src="/clips/grid.webm" type="video/webm" />
          <source src="/clips/grid.mp4" type="video/mp4" />
        </video>
      </div>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <div class="frame frame-bare" x-ds-clip>
        <video muted loop playsinline preload="none" loading="lazy" poster="/clips/grid.avif" controls>
          <source src="/clips/grid.webm" type="video/webm" />
          <source src="/clips/grid.mp4" type="video/mp4" />
        </video>
      </div>
    </div>
  </div>
  <button class="clip-toggle clip-toggle-hang" type="button" data-ds-motion-toggle hidden></button>
</div>

Reference

Read from components/clip.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.clip.clip-toggle.clip-toggle-inline.clip-toggle-plate.clip-hang.clip-toggle-hang.clip-hang-panel.clip-toggle-hang-start.clip-toggle-hang-above
SETTINGSSet the first on one element; a theme sets the second for all of them.--clip-plate β€” falls back to --ds-cream--clip-plate-ink β€” falls back to --ds-ink-on-cream--clip-plate-shadow β€” falls back to --ds-shadow-floating--clip-hang-radius β€” falls back to --ds-radius-frame
READS--ds-accent--ds-clip-plate--ds-focus-ring--ds-focus-ring-inset--ds-focus-width--ds-gap--ds-hairline--ds-ink--ds-ink-soft--ds-motion-fast--ds-pad-toggle--ds-radius--ds-radius-control--ds-raised--ds-rule--ds-rule-strong--ds-sunk--ds-toggle-glyph

Notes

THE HANGING TAB NEEDS A WRAPPER, AND THE REASON IS NOT STYLE

A frame clips its overflow, which is its job. So a control placed outside it from within is not merely cropped β€” it is a control WCAG 2.2.2 requires that has become invisible. Turning the frame's clipping off to make room would trade a legal requirement for a crop, which is not a trade anyone should be offered.

So clip moves off the frame and onto a wrapper, and the frame and the button become siblings inside it. Nothing stops clipping, nothing is positioned outside anything, and the tab is simply the next thing in a column. Put x-ds-clip on the wrapper: the directive looks for a video and a .clip-toggle beneath whatever carries it, so one directive drives both shapes with no branch in it.

It is also in flow, where the other variants are absolute. A tab takes room; an absolutely positioned one reserves none, so whatever follows the clip slides underneath the control β€” and the page that breaks on is never the page it was written on.

IT KEEPS ITS PLACE ON THE WAY INTO THE WINDOW, AND BACK

A clip with a lightbox is one piece of film in two video elements. Left alone they are strangers: the big one starts at zero however long you watched the small one, and the small one is still where it was when you close the window. x-ds-clip hands the position over on open and takes it back on close, so you land where you were. Measured both ways at 0.00s of drift.

It needs no attribute. A clip containing a [commandfor] that names a dialog with a video in it is the lightbox shape β€” there is nothing else it could be β€” and asking a caller to say so twice would be a second spelling of what the markup already says. It declines unless both elements are told to play the same file, because seeking an unrelated video to somebody else's timestamp is a worse bug than not syncing at all.

The page's copy stands down while the window is open. Two decoders on one film is battery a reader pays for, and they drift, so the hand back would jump.

πŸ›‘ A seek before the film has loaded is thrown away, which is the trap this cost an evening to find. These clips are preload="none", so the window's copy has readyState 0 when it opens. Setting currentTime then appears to work β€” the property reads the value back and keeps it β€” and then play() loads the resource and resets it to zero. So the seek waits for loadedmetadata when there is nothing loaded yet, and happens at once when there is.

A CLIP IN A LIGHTBOX HAS NO CONTROL, AND THAT IS CORRECT

Every clip on this page carries a pause, and the note above says why that is not optional. A clip inside a modal is the one place it comes off β€” not as an exception granted, but because the criterion has stopped applying. 2.2.2 governs motion presented in parallel with other content, and it is met by pause, stop or hide. A lightbox is neither half of that: the reader opened it on purpose, it fills the screen, and there is nothing beside it to be distracted from. It is the content.

And the mechanism is already there, three times over β€” modal-close, the veil, and Escape β€” by pointer, by keyboard and by touch. The clip is muted throughout, so audio control never enters into it. What you gain is the picture: a plate is a cream square with a shadow lying in the middle of the only thing the window was opened to show. Look at the lightbox on the modal page β€” its clip is bare.

Anywhere the reader did not ask for the window, none of this applies and the pause is owed.

ITS BORDER DOES NOT ANSWER A POINTER

Every other toggle takes its border to the accent on hover. A tab cannot: three of its sides would light up and the fourth could not follow, because the fourth is the frame's border and a component does not reach into its neighbour. What that draws is an accent U with a gap in it, which reads as a rendering fault rather than as a hover.

So the glyph and the fill carry it instead β€” which is the honest place for it here anyway. The tab is pretending to be part of the picture's outline, and an outline that changes colour when a pointer passes has stopped pretending. Focus shows the same pair plus the ring, because a keyboard user should see exactly what a pointer user sees.

IT BORROWS THE FRAME'S BORDER AND THE FRAME'S CORNER

--ds-rule and --ds-radius-frame, not --ds-rule-strong and --ds-radius-control, because a tab is pretending to be part of the picture's outline rather than a button standing near it. The merged edge carries no border at all β€” the frame's own is the shared line, and a second one there is the two-pixel rule this design never has.

--ds-radius-frame exists so that "matches the picture" is true by construction rather than by coincidence. Frames are square today; the day a theme rounds them, the tab rounds with them instead of going on being a 5px control corner beside a rounded photograph.

Which is exactly why a tab hanging under something that is not a picture needs to say so. clip-hang-panel on the wrapper swaps the media corner for --ds-radius, the one every other box in the system reads. You can see the difference on this page: the theme you are reading squares its frames at 0px and rounds everything else at 5px, so without that class the tab under the mesh above would go square while the mesh stayed round. The wrapper says it because the wrapper is the only thing that knows what is inside it.

THERE IS NO AUTOPLAY ATTRIBUTE, AND THAT IS THE WHOLE PERFORMANCE STORY

With preload="none" not one byte of video is fetched until something asks for it, and with loading="lazy" not even the poster is, until it is near the viewport. So a page of clips costs what the same page of pictures costs, and a reader who never scrolls that far pays nothing at all.

autoplay would have started the clip while it was nowhere near the viewport, and before any script could ask whether the reader wanted motion. An IntersectionObserver starts it when it is actually visible β€” better for the battery, better for the network, and the only version that can honour a motion preference.

WITHOUT JAVASCRIPT IT IS A PICTURE

The markup carries controls and the button ships hidden. With no script the reader gets the poster and the browser's own play button, and zero video bytes are fetched β€” which is precisely the still image this component replaces, not a degraded version of anything. x-ds-clip swaps them over: it removes controls and reveals ours. A control that cannot work is never on screen, in either direction.

ON SCREEN IS NOT THE SAME AS BEING WATCHED

A loop left running repaints the page for as long as the tab is open. Measured on a published page: 150 presented frames every five idle seconds β€” thirty a second β€” against one with the clip paused, and nothing else on that page was awake. It was reported from a tiling window manager, where a browser window the reader had clicked away from went on flickering, because it was still being handed thirty surface updates a second.

So x-ds-clip asks three things rather than one: is it on screen, is the document visible, and does this window have focus. The second does not imply the third β€” a window sitting beside the one being typed in is visible, and is not being watched. It starts again the moment focus comes back, and none of it overrules a reader who pressed pause.

THE PAUSE CONTROL IS NOT OPTIONAL

WCAG 2.2.2 is Level A: motion that starts on its own and runs past five seconds β€” which a loop does by definition β€” owes everyone a way to stop it. prefers-reduced-motion is the floor rather than the ceiling, because it only helps the people who already found that setting. Someone who simply finds looping motion distracting has never been asked.

So the control is part of the component rather than an option on it, and a manual pause sticks: scroll away and back and it stays paused. An observer that starts it again has overruled the person who asked it not to, which is the detail that makes a pause button feel broken.

MAKING THE THREE FILES

The clip above is this recipe's own output β€” 5.3 kB of webm, 8.3 kB of mp4 and a 1.7 kB poster, for four seconds at 960Γ—540. Three of these flags are doing unobvious work and all three matter.

# 1 Β· the webm β€” smaller, and the browser tries it first
ffmpeg -i source.mov \
  -c:v libvpx-vp9 -crf 38 -b:v 0 -row-mt 1 \
  -pix_fmt yuv420p -an  clip.webm

# 2 Β· the mp4 β€” the one that plays everywhere
ffmpeg -i source.mov \
  -c:v libx264 -crf 30 -preset slow \
  -pix_fmt yuv420p -movflags +faststart -an  clip.mp4

# 3 Β· the poster β€” frame zero, so playback does not jump when it starts
ffmpeg -i source.mov -vf "select=eq(n\,0)" -frames:v 1 \
  -c:v libsvtav1 -crf 30  clip.avif
  • -an strips the audio track entirely. muted only silences playback β€” a muted clip with a track still ships those bytes, and a track that does not exist can never be un-muted by a stray script.
  • -movflags +faststart moves the mp4's index to the front. Without it the browser must fetch the end of the file before it can begin, which on a lazily-loaded clip is a stall exactly where nobody is watching for it.
  • -pix_fmt yuv420p β€” anything else fails to decode on Safari, silently, on the one browser you are least likely to be testing in.

Take the poster from frame zero rather than a nice-looking frame further in, so that the moment playback starts there is no jump. And keep the loop seamless β€” if the last frame does not match the first, the join is the only thing anybody will see.

TWO ASPECT RATIOS

There is no declarative way to do this any more. <source media> was removed from the HTML specification in 2014; Chrome and Firefox dropped it and only Safari kept it, so a media query inside a <video> works on one browser and is ignored by the rest. Nothing here pretends otherwise.

What works is two clips and one hidden β€” and because both carry preload="none", the hidden one costs nothing beyond its own poster, which loading="lazy" also defers. Set --ratio per instance on each .frame, exactly as a picture does, and let the consuming page decide which is shown. That is a page-level decision about content, and the library does not have an opinion about it.

WHAT THIS IS NOT

Not a video player: no sound, no scrubbing, no fullscreen, no captions. A clip with dialogue in it is a different component with different obligations, and reaching for this one there produces something that cannot be paused anywhere useful and cannot be understood without hearing it. Not a background hero either β€” it sits in a .frame with a stated ratio, so it cannot become full-bleed motion behind text.