Show codeHide code
src/motion - 2 files
Motion is the easiest thing in a framework to get wrong in a way nobody notices until it hurts someone. Two rules govern everything here: only four properties ever move, and a reader who has asked for less motion never waits on an animation.
The markup panels below are read out of the live examples by site.js. With scripting off the examples still render and the panels stay empty.
transform, opacity,
clip-path and filter. Those
are the properties a compositor can run off the main thread. Everything else forces
layout or paint on every frame, which is why nothing in this framework animates a
width, a height, a margin or a font size.
transition-property is always an explicit list and never
all, because all silently
commits to animating whichever layout property happens to change next.
The reduced-motion guarantee is framework wide rather than a per-rule promise. The reset clamps every animation and transition in the document to 0.01ms, so an author who forgets the media query still cannot ship vestibular-triggering motion. Infinite animations are handled separately, by cancelling the animation name outright: a global clamp would leave a spinner iterating ten thousand times a second, which is worse than one that turns.
The setting lives in the operating system, so a page cannot normally show you what it does. The switch below stands in for it: it writes, onto every example on this page, the same declarations the shipped reduce blocks make. Turn it on and every button further down still works, the state still arrives, and nothing travels to get there.
It is a replica and not the real thing, which is worth being exact about. It cannot
change what prefers-reduced-motion reports, so a
component that branches on the query itself is unmoved by the switch, and the line
above the markup panel says which of the two you are looking at. What it does write
is what the files write: --bs-animate-name: none to
cancel an animation rather than clamp it,
--bs-transition-duration: 0.01ms so
transitionend still fires and a state machine waiting on
it does not stall, and the same pair on the reveal.
The real setting is in your system preferences, under accessibility or display, and is usually called something like "reduce motion". Turning it on there is the honest test, and this page is built to pass it.
One block that components and your own code attach to. The default is 180ms on the ease-out curve: long enough to be seen as motion, short enough that someone who clicks twice is not waiting for the first click to finish.
The three leave together and arrive apart, which is the whole point of having a duration scale rather than one number. They also all hover, so a pointer shows the same block doing the job it was written for. The transform each one takes is in the markup rather than in the script, so the value you copy is the value you watched: down three quarters of a rem and in to 94 percent, which stays inside the card and cannot extend the page sideways on the way.
| Token | Use it for |
|---|---|
--ease |
A change with no direction. Symmetric. |
--ease-out |
Something arriving. The default. |
--ease-butter |
Something leaving, or settling under its own weight. |
Eight named animations. Each modifier sets a custom property rather than the
animation shorthand, so duration, easing and delay stay
overridable per element without restating the whole thing. Every animation ends at the
element's natural state and uses
fill-mode: both, so a clamped or cancelled animation
lands on the visible end state and never on the hidden start state.
Play again is not a class being toggled. Removing and re-adding a class in the same
task is coalesced into no change at all, so the control cancels the animation name
inline, reads a layout property to force the engine to resolve that, and then drops
the inline value again. That is the whole of
src/behaviour/motion-replay.js for this example, and it
leaves the markup it touched exactly as it found it, which is why the panel below
still shows clean markup after you have pressed the button.
A spinner is not content. If it communicates state, that state has to be in the
accessibility tree: an aria-hidden spinning box next to a
live region that says "Loading", not a bare spinning box with no name. It is in this
example because it is the case the reduce block treats differently: turn the preview
on and it stops and closes into a ring, rather than iterating at 0.01ms per turn.
The failure mode this file is built around: a reveal implemented as "hide in CSS, show
in JavaScript" turns into a blank page whenever the script is blocked, fails to parse,
throws before it observes, or lands in a browser without
IntersectionObserver. That is not an edge case, it is the
most common way a page ships unreadable.
So nothing here hides anything until the script has said it is running. You write the
resting markup visible, with the class on it. Your script sets
data-bs-reveal="ready" on the document element as its
first act, and only then does the hidden resting state apply. If the script never
runs, the attribute is never set and every element stays visible. Turn scripting off
and reload: this page still reads in full.
/* first act, before anything is observed */
document.documentElement.dataset.bsReveal = 'ready';
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('bs-reveal--in');
io.unobserve(entry.target);
}
});
for (const el of document.querySelectorAll('.bs-reveal')) io.observe(el);
Reveal again drops the arrived class, forces the same reflow, and puts it back, so the four cards run their stagger from the start. Watch the delay open across the indices rather than all four landing together, and then turn the preview above on and press it again: they arrive at once, because the stagger is a delay and the reduce block takes the delay to zero along with the duration.
Stagger is declarative: set --bs-reveal-index inline and
the delay is the index times the stagger. Keep the index under about eight, past which
the last item arrives long after the reader has looked at it. The hidden resting state
lives entirely inside a no-preference query, which is stronger than clamping the
duration: a reader who has asked for less motion never has content hidden from them at
all, whether or not the script runs.
The inline-axis variants move an element sideways, which can extend the scrollable area while it is pending, so the section that contains them needs a containing block that clips. Without it a right-moving reveal adds a horizontal scrollbar at 320px on the way in.
Opt-in only. Naming an element gives the browser a pair of snapshots to cross-fade between across a DOM change or a same-document navigation. It is deliberately not applied to the document root: taking over the default root transition would be a global side effect of importing one component file.
<article class="bs-vt" style="--bs-vt-name: post-42"> ... </article>
A name has to be unique in the document at the moment the transition runs. Two elements sharing one name aborts the whole transition, so anything that appears more than once sets the name per element. The rule that opts a whole site into cross-document transitions is not shipped here: it changes navigation behaviour for every page that loads the stylesheet, which is your decision and not a component's.