Animations
WireKit ships a small motion system: a handful of design tokens, the 11 keyframe preset bases below (each with in and out variants), corresponding utility classes, and the wirekitAnimate Alpine helper that drives the <x-wirekit:: Blade wrapper.
Everything in this system honors prefers-reduced-motion: reduce — the global @media block in dist/ snaps animation-duration to 1ms for any class matching wk-animate-*, so reveal-on-scroll degrades gracefully to "appear in final state".
Motion tokens
| Token | Default | Purpose |
|---|---|---|
--motion-wk-duration-fast |
150ms |
Quick state changes — hover, focus rings. Aliased from legacy --transition-wk-duration. |
--motion-wk-duration-normal |
300ms |
Default for all wk-animate-* utilities. Material / shadcn-aligned. |
--motion-wk-duration-slow |
600ms |
Hero-section entrance, staged reveals. |
--motion-wk-easing-out |
cubic-bezier(0.16, 1, 0.3, 1) |
Ease-out cubic — "decelerates into final state" — natural reveal feel. |
--motion-wk-easing-in |
cubic-bezier(0.7, 0, 0.84, 0) |
Ease-in cubic — "accelerates out of state" — used for out-presets that fade content away. |
--motion-wk-easing-spring |
cubic-bezier(0.5, 1.5, 0.5, 1) |
Spring-overshoot — used for bounce, flip, rotate, spring presets to give visible elasticity. |
--motion-wk-delay-none |
0ms |
The delay rung a reveal starts from. |
--motion-wk-delay-sm |
75ms |
Enough to read as deliberate rather than as lag. |
--motion-wk-delay-md |
150ms |
The step a staggered group is usually built on. |
--motion-wk-delay-lg |
300ms |
Second-tier content — a subtitle after its heading. |
--motion-wk-delay-xl |
500ms |
Last in a sequence; past this a reader stops waiting for it. |
<x-wirekit:: resolves to var(--motion-wk-delay-md); a raw number (:delay="1000") bypasses the rungs and sets the milliseconds directly. Override a rung in your @theme block and every reveal using it moves together — which is the reason they are tokens rather than five numbers spread across the catalog.
The 11 preset bases
Each base has both an -in (appear into final state) and -out (leave to opacity-0 / off-screen) variant. The -in variants are by far the more common case.
| Base | Best for | Easing | Direction |
|---|---|---|---|
fade |
Content reveals where shape doesn't matter | out |
Opacity only |
slide-up |
Cards / list items entering from below | out |
translateY 1rem → 0 |
slide-down |
Notifications, alerts dropping in | out |
translateY -1rem → 0 |
slide-left |
Sidebar reveals from the right | out |
translateX 1rem → 0 |
slide-right |
Sidebar reveals from the left | out |
translateX -1rem → 0 |
scale |
Cards in feature grids — subtle pop | out |
scale 0.92 → 1 |
zoom |
Hero images, modal entrances | out |
scale 0.5 → 1 |
flip |
Card flips, decision reveals (3D) | spring |
rotateY -90deg → 0 |
rotate |
Tag-pull / reveal-with-spin (decorative) | spring |
rotate -12deg + scale 0.9 → 1 |
bounce |
Hero CTA appearing — calls attention | spring (slow) |
scale 0.3 → 1.05 → 0.9 → 1 |
spring |
List items with subtle elasticity | spring (slow) |
translateY 0.75rem + scale 0.95 → settled |
Interactive preset gallery
Each preview wraps a <x-wirekit:: in <x-wirekit:: and uses the default trigger="viewport": the animation fires when the card scrolls into view. The :once="false" prop lets the animation re-fire each time the card crosses back into the viewport, and the replay icon in the preview's top-right corner replays without scrolling. All previews use duration="slow" (600ms) so the motion is easy to follow.
The <x-wirekit:: component-docs page shows the same 11 presets via the same Blade wrapper. The two pages exist for different reasons: /animations is the motion-system reference (tokens, keyframe definitions, helper API), /components/ is the component-API page. The gallery itself is identical on purpose — one visual atlas, two entry points.
Fade family
Slide family
Scale family
Spring family (overshoot easing)
Out variants — animate to invisible
Every preset ships an -out counterpart that animates the element away to opacity: 0. The card below fades out the moment it enters the viewport — every other preset follows the same shape (<preset>-out). Use the Replay button at the top-right of the preview to re-fire the animation.
The element is invisible after an -out animation finishes. Pair -out with wire:replace, Alpine x-show, or a manual DOM removal — never as a standalone reveal that leaves a permanently-invisible card on the page.
Duration comparison
Same slide-up-in preset rendered with each of the three duration tokens. At fast (150ms) the motion is barely perceptible; at slow (600ms) the elasticity becomes obvious.
Utility classes (no Alpine)
For developers who don't want Alpine in the loop, the utility classes apply the keyframe directly:
<div class="wk-animate-fade-in" data-replayable="true">Reveal me</div>
<div class="wk-animate-slide-up-in wk-animate-slow" data-replayable="true">Slow slide-up</div>
Pair any preset class with one of the duration modifiers (wk-animate-fast, wk-animate-normal, wk-animate-slow) to override the default 300ms.
Replay-button contract for utility-class animations
WireKit Blade components that drive animations (<x-wirekit::, <x-wirekit::, <x-wirekit::, <x-wirekit::, etc.) automatically emit data-replayable="true" on their root element. docs.'s preview chrome detects this attribute and surfaces a ↻ Replay button on every preview block so readers can re-watch the animation without reloading the page.
When you paint an animation via the raw wk-animate-* utility classes (no Blade component), there's no PHP-side render path to emit the attribute automatically. To keep the replay button surfacing on utility-class animations, add data-replayable="true" to the same element that carries the wk-animate-* class:
<span class="wk-animate-fade-in" data-replayable="true">Reveal me</span>
The attribute is otherwise inert — it adds no styling, no JavaScript binding, no accessibility implications. It exists purely as a discovery marker for replay tooling. On a developer app without the replay surface, the attribute is a harmless no-op.
The wirekitAnimate Alpine helper
When you need finer control — viewport triggering, click triggering, manual dispatch, conditional once/repeat behavior — use the Alpine helper directly:
<div data-replayable="true" x-data="wirekitAnimate('slide-up-in', { trigger: 'viewport', threshold: 0.4 })">
…content…
</div>
<div data-replayable="true" x-data="wirekitAnimate('bounce-in', { trigger: 'click', once: false })">
Click to bounce again
</div>
<div data-replayable="true" x-data="wirekitAnimate('flip-in', { trigger: 'manual' })">
…animated when window.dispatchEvent(new Event('wirekit:reveal'))
</div>
| Option | Type | Default | Notes |
|---|---|---|---|
trigger |
string | 'viewport' |
'viewport' / 'click' / 'manual' |
once |
bool | true |
If false, re-fires on every trigger |
threshold |
float | 0.4 |
How much must be in view before it fires (only used for viewport). A target taller than the screen fires once it fills that share of the viewport's height |
duration |
string | 'normal' |
'fast' / 'normal' / 'slow' |
Most developers should use the <x-wirekit:: Blade wrapper instead — same options, cleaner API, validates against the preset enum.
Flashing a row that changed
x-wk-flash lights a row up in a tone for a moment when it changes: green for an addition, amber
for one fewer, red for a removal. It works on a list item, a table row or a card, and the server
drives it by writing three attributes on the row:
| Attribute | What it says |
|---|---|
data-wk-flash |
The tone: success, warning, danger, info, primary or neutral |
data-wk-flash-key |
A value that is new with every change, a counter or a time. A new key plays the flash again, in the same tone too |
data-wk-flash-leave |
The row is on its way out. It flashes once more and is taken out of the page when the flash ends |
{{-- 1. The directive on every row; the attributes only on the row that changed --}}
<x-wirekit::table.row
x-wk-flash
:data-wk-flash="$changed[$line->id]['tone'] ?? null"
:data-wk-flash-key="$changed[$line->id]['key'] ?? null"
>
{{-- 2. The cells of the line --}}
</x-wirekit::table.row>
A row that is removed has to be drawn once more to be seen leaving: render it in the render that
removes it, with data-wk-flash="danger" and data-wk-flash-leave, and leave it out of the next
one. While it flashes it is hidden from assistive technology and cannot be pressed.
The tints are tokens, --color-wk-flash-success and its siblings, so a quieter or a stronger flash
is one value in your theme. The flash lasts --motion-wk-duration-slow.
Under reduced motion the tint does not fade. It appears at once, stays for the same time and is
gone at once, so the change is still there to see. data-reduce-motion on <html> decides first,
as it does for every other animation here.
Bringing a result into view
x-wk-scroll-into-view brings a region into view and gives it the focus when an action makes it
appear or change: a form that opens at the top of the page, a key shown once, a message above a
long list. Without it, a reader who pressed a button far down the page sees nothing happen. The
server decides when, through one attribute:
| Attribute | What it says |
|---|---|
data-wk-scroll-key |
A value that is new with every result to show, an id or a time. The region comes into view when it appears with a key and again whenever the key changes. Without a key it stays where it is, so a page that loads with the region on it does not jump |
{{-- 1. Rendered after the action, with a key that is new every time --}}
@if ($createdToken)
<section x-wk-scroll-into-view data-wk-scroll-key="{{ $createdToken->id }}">
<x-wirekit::card>
<x-wirekit::clipboard-button :value="$createdToken->plainTextToken">Copy the new key</x-wirekit::clipboard-button>
</x-wirekit::card>
</section>
@endif
// 2. The action that produces it, in your Livewire component
public function createToken(): void
{
$this->createdToken = auth()->user()->createToken($this->name);
}
A region already in full view is not scrolled. The focus moves to it either way, so the keyboard
goes on where the reader is looking; a region that cannot take the focus gets tabindex="-1" for
that, and keeps it through the component's next render for as long as it holds the focus, so a
request that answers while the reader is still there does not move the focus to the page. Add
.nofocus, as in x-wk-scroll-into-view., to leave the focus where it is. A sticky header
that would cover the region is answered by the region's own scroll-margin-top, which the scroll
respects: class="scroll-mt-20".
The same works in an application whose main area scrolls instead of the window. There "in full
view" means in full view of that area, so a region cut off by its top edge, under a fixed header,
comes in like one far above it. The band a pinned header or bar reports as its area's
scroll-padding, as a sticky page header does, counts as covered too, so
a region standing under that header comes into view. A glide that is stopped on the way is followed by a jump once it has
had its time, so the region does not stay out of view. When one render brings several regions, the first of them on the page comes into view and
takes the focus, and the others stay where they are.
Under reduced motion the page jumps to the region instead of gliding. data-reduce-motion on
<html> decides first. For the other direction, back to the top of a long page, there is the
scroll-to-top button.
Reduced motion
The animation system is fully accessible:
-
The global
@media (prefers-reduced-motion: reduce)block inwirekit.csssnapsanimation-durationto1mson WireKit's own elements and on everything inside them — the selector is[class^="wk-"] *, a descendant combinator, and the!importanton it is load-bearing rather than lazy (Tailwind's duration utilities sit at the same specificity, so without it the winner would depend on how a JIT build happened to order its output). In practice that is your whole page.wk-scrollbaris the class WireKit puts on<html>and<body>, so once it is there every element in the document is a descendant of awk-element and the clamp reaches all of it: a bare<div>withanimation: spin 2sappended straight to<body>is clamped to the same1msunder reduce. Treat the rule as document-wide rather than component-scoped. If your application needs motion anyway, the lever isdata-reduce-motion="no-preference"on<html>— see theming. -
One element can keep its motion:
data-wk-motion="essential". It releases that clamp for the element it sits on and everything inside it, in both halves — the OS one and thedata-reduce-motion="reduce"one. It is for motion that IS the information: a live trace, a queue that visibly moves work along, a shape whose stillness would read as a hang. WCAG 2.3.3 exempts motion essential to the functionality, and that exemption is the whole boundary here — an animation that merely looks better in motion does not qualify, and marking it makes your page worse for the reader who asked. It does not restart WireKit's own indicators: a spinner, a skeleton and an indeterminate progress bar are stopped by rules of their own rather than slowed, and each announces its state throughrole="status", so nothing is lost with the movement.{{-- The trace is the reading; frozen, it says nothing. --}} <div data-wk-motion="essential" class="ecg-trace">…</div> -
Element ends up in its final state immediately; no transform, no opacity transition, no perceptible motion.
-
The Alpine helper still adds the class on reduced-motion (so
opacity: 0keyframes don't leave the element invisible) — the global block handles the snap.
For developers building their own animation systems on top of WireKit's tokens, mirror this pattern: either define your animations inside @media (prefers-reduced-motion: no-preference) { ... } OR add a global override that targets your custom utility classes.
View transitions
Livewire animates a DOM change through the browser's View Transitions API when you name the elements that survive it:
{{-- 1. The same name on both sides of the change is what pairs the two states. --}}
<x-wirekit::card wire:transition="summary">
{{ $summary }}
</x-wirekit::card>
WireKit ships no JavaScript for this, on purpose. Livewire already detects
document. before using it, turns the animation off under
prefers-reduced-motion: reduce, and steps aside while a modal <dialog> is open. A second
driver in this package would race the first one on the same mutation, and the loser would be
whichever snapshot the browser took second.
A browser without the API applies the same DOM change instantly. Nothing about the result depends on the animation — it is polish on top of an update that already happened.
When the shape changes too
Two snapshots with different aspect ratios are stretched into each other by default, which reads as a squash rather than as a move. The fix is per transition name, so it belongs in your own stylesheet next to the name you chose:
@supports (view-transition-name: none) {
::view-transition-old(summary),
::view-transition-new(summary) {
/* 1. Cross-fade off: both snapshots stay opaque and the new one covers the old. */
animation: none;
mix-blend-mode: normal;
/* 2. Fill the group box and crop, rather than stretch into the other aspect ratio. */
height: 100%;
overflow: clip;
object-fit: cover;
}
}
Per-component animateIn props
Several marketing-heavy components accept an animateIn prop that wires the Reveal helper internally — saves the wrapper:
{{-- Equivalent to wrapping in <x-wirekit::reveal preset="fade-in"> --}}
<x-wirekit::card animateIn="fade">…</x-wirekit::card>
<x-wirekit::feature animateIn="slide-up" icon="bolt" title="Fast">…</x-wirekit::feature>
<x-wirekit::hero animateIn="zoom-in">…</x-wirekit::hero>
Components with built-in entrance transitions — modal, drawer, toast, tooltip, dropdown, popover — do NOT accept animateIn (would conflict with their own motion).
See each component's docs page for the full list of supported animateIn presets.
Tailwind-UI naming-convention aliases
Both <x-wirekit:: and the per-component animateIn prop accept four short-form aliases for the directional slide presets — convenient when porting from fade-up / fade-down style conventions used by Tailwind UI / Headless UI / Framer Motion patterns:
| Short form | Canonical preset |
|---|---|
fade-up |
slide-up-in |
fade-down |
slide-down-in |
fade-left |
slide-left-in |
fade-right |
slide-right-in |
{{-- These render identical motion: --}}
<x-wirekit::reveal preset="fade-up">…</x-wirekit::reveal>
<x-wirekit::hero animateIn="fade-up">…</x-wirekit::hero>
{{-- …and are equivalent to the canonical form: --}}
<x-wirekit::reveal preset="slide-up-in">…</x-wirekit::reveal>
<x-wirekit::hero animateIn="slide-up-in">…</x-wirekit::hero>
The aliases resolve at component-render time; the underlying Alpine helper always sees the canonical slide-*-in preset name. The map is byte-identical across every surface — guarded by Fade so a divergence between <x-wirekit:: and animateIn blocks the build.
Pitfalls
- Above-the-fold + viewport trigger — element is already intersecting at page load; the animation fires immediately and the user sees no perceived motion. For hero-section reveals, use
trigger="manual"+ dispatch the event onDOMContent.Loaded - Out-presets default state —
*-outkeyframes end atopacity: 0; the element is invisible after the animation. Pair them withwire:replaceor Alpinex-showtoggles, never as a standalone reveal. - Don't bake animations into form components — input focus rings, validation transitions, and other state changes are handled by
--transition-wk-duration(150ms). Reveal animations are for entrance / exit of large content blocks, not micro-interactions.
See also
<x-wirekit::— the Blade wrapperreveal> <x-wirekit::— counter-animation (separate Alpine plugin)stat animate> - Theming — design-token reference
- WAI-ARIA APG: Animation from Interactions