Motion & interactions
The movement keel ships and the modern interactions it wires up — all CSS, all token-timed, all off for reduced-motion users.
Entry animations
.k-anim-fade, .k-anim-up,
.k-anim-pop, and .k-anim-slide are
one-shot entry animations: they run once when the element mounts
— on page load, or the moment your script inserts it — and then
they're done. Users with prefers-reduced-motion set
simply see the content, no animation. They fire on mount, not on
scroll — for scroll-triggered reveals use .k-reveal
below. Duration and delay are knobs: every animation reads
--k-anim-duration (450ms by default) and
--k-anim-delay (0ms), so retiming one element is a
single inline custom property — no new class. Reload the page to
watch these four again:
Fade
.k-anim-fade — opacity only.
Up
.k-anim-up — fades in while rising.
Pop
.k-anim-pop — fades in while scaling up.
Markup
<div class="k-card k-anim-fade">…</div>
<div class="k-card k-anim-up">…</div>
<div class="k-card k-anim-pop">…</div>
<div class="k-card k-anim-slide">…</div>
<!-- retime one element with the knobs — no new class -->
<div class="k-card k-anim-up"
style="--k-anim-duration: 800ms; --k-anim-delay: 200ms">…</div>
<!-- also runs when your script mounts something -->
<!-- list.append(Object.assign(document.createElement("div"),
{ className: "k-card k-anim-pop" })) -->
Stagger
Wrap any group of entry-animated children in
.k-stagger and they animate in sequence — the
wrapper hands each child a growing --k-anim-delay
(80ms per step, first six children; everything after shares the
last step). It works with any k-anim-*, on any
layout container. Reload to watch these three arrive one after
another:
First
0ms delay.
Second
80ms delay.
Third
160ms delay.
Markup
<div class="k-thirds k-stagger">
<div class="k-card k-anim-up">…</div>
<div class="k-card k-anim-up">…</div>
<div class="k-card k-anim-up">…</div>
</div>
<!-- manual control: set the delay knob yourself instead -->
<div class="k-card k-anim-up" style="--k-anim-delay: 350ms">…</div>
Hover lift
.k-lift is the generic hover lift: any element
wearing it rises 3px and gains the large shadow on hover. It's
the same gesture .k-card--hover makes, minus the
card-specific styling — .k-card--hover also
strengthens the border and tunes its shadow to the card, so
prefer it on cards; reach for .k-lift everywhere
else. Hover these:
Lifted card
.k-lift on a plain card.
Markup
<div class="k-card k-lift">…</div>
<button class="k-btn k-lift">Lifted button</button>
<!-- on cards, prefer the tuned variant -->
<div class="k-card k-card--hover">…</div>
Scroll progress
.k-progress-scroll is a fixed 3px bar across the
top of the viewport, painted with --k-gradient,
that fills from left to right as the page scrolls — a pure CSS
scroll-driven animation (animation-timeline:
scroll()), no scroll listener, no JavaScript. Because
it's position: fixed against the page scroller,
there's no honest way to demo it inside a box on this page —
and adding it to the docs chrome itself would be noisy. It's one
line, live on any page you drop it into:
Markup
<body>
<div class="k-progress-scroll"></div>
…rest of the page…
Support: scroll-driven animations are Chromium today. Elsewhere the bar is simply absent — nothing breaks, nothing to polyfill. Reduced-motion users never see it.
Scroll reveal
.k-reveal fades and lifts content in as it
scrolls into view — no observer, no script; it's a CSS
scroll-driven animation (animation-timeline: view())
that plays across the first stretch of the element's entry, so
it's progressive rather than a switch. It degrades honestly:
browsers without scroll-driven animations, and every
reduced-motion user, simply see the content, already in place.
The card below wears it — scroll it out of view and back:
Revealed on scroll
This card animates as it enters the viewport, driven by scroll position alone.
Markup
<div class="k-card k-reveal">
<h4>Revealed on scroll</h4>
<p>No JavaScript — a scroll-driven CSS animation.</p>
</div>
Animated disclosure
Every keel <details> — including the
accordion, and the Markup blocks on this very page — now
animates open and closed. Modern CSS does it without a wrapper
or a script: interpolate-size: allow-keywords lets
block-size: auto transition, and the
::details-content pseudo-element is what grows.
You write nothing; it's free on the base element. Try it:
Watch the height ease open
The content doesn't snap into place — its height
transitions on the same --k-ease token every
other keel transition rides.
Add name="…" to a group of details for
native one-open-at-a-time, and the closing one animates
shut as the next opens.
Markup
<details>
<summary>Watch the height ease open</summary>
<p>Nothing extra — every keel details animates.</p>
</details>
Support: interpolate-size and
::details-content are Chromium today, behind an
@supports guard — elsewhere details opens
instantly, exactly as it always has.
Auto-growing textarea
Every keel textarea grows with its content —
field-sizing: content in the base layer, capped at
24rem so a pasted essay can't swallow the page.
No autosize script, no keystroke listener. Type a few lines
into this one and watch it stretch:
Markup
<!-- nothing to add — every textarea auto-grows -->
<textarea placeholder="Type a few lines…"></textarea>
Support: field-sizing is Chromium today,
behind @supports — older browsers keep the normal
fixed-height textarea with its resize handle. More on form
controls in Forms.
Overlay choreography
No class to learn here — every keel
<dialog>, [popover], and
.k-drawer already animates in and out:
entries via @starting-style, exits via
transition-behavior: allow-discrete on
display and overlay, so the element
stays visible while it fades away. The backdrop dims on the same
timing. Browsers without @starting-style get a
plain instant show/hide. The overlays themselves — with live
demos — are on the
Overlays page; here's how
you retune the tempo, since all of it rides one token:
Markup
<style>
/* slow every overlay (and every keel transition) at once… */
:root { --k-ease: 260ms ease; }
/* …or retune one dialog without touching the token */
.my-slow-dialog {
transition-duration: 400ms;
}
</style>
View transitions
The modern page-to-page cross-fade is a two-line opt-in — but it's your opt-in, in your site's CSS, not keel's. Navigation behavior belongs to the site, not the framework, so keel doesn't ship it enabled. Add this and same-origin navigations fade instead of flashing white:
Markup
<style>
/* in YOUR site css — opts every same-origin navigation in */
@view-transition { navigation: auto; }
/* optional: choreograph it yourself */
::view-transition-old(root) { animation-duration: 200ms; }
::view-transition-new(root) { animation-duration: 200ms; }
</style>
Support: cross-document view transitions are Chromium
today; other browsers simply navigate normally. Name elements
with view-transition-name and target
::view-transition-old() /
::view-transition-new() for custom
choreography.
The rule
Every piece of motion on this page respects
prefers-reduced-motion — reduced-motion users see
content in place, instantly, always. Timing rides the tokens:
transitions on --k-ease, animations on
--k-anim-duration and --k-anim-delay —
so the design system controls the tempo of the whole site from
three knobs. And nothing here needs JavaScript: entry, stagger,
reveal, progress, disclosure, auto-grow, overlays, view
transitions — all of it is CSS, degrading honestly where the
platform hasn't caught up.