Skip to content

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.

Slide

.k-anim-slide — fades in from the side.

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.