slik 0.2.0

A binder-first Motion-inspired animation framework for Leptos
Documentation

Slik

Slik is a binder-first, Motion-inspired animation framework for Leptos.

It is built around a small numeric motion core with three public layers:

  • use_motion for binding animation directly to typed NodeRefs
  • slik::html::* for thin motion-enabled HTML component sugar
  • MotionValue for low-level animated scalar state

v0.2 is a clean rewrite over the earlier wrapper-first MVP. This README documents the current API only.


Why Slik exists

Leptos already gives you precise reactive control over state and DOM ownership. Slik focuses on the missing piece: making motion feel native to that model.

The design goals are intentionally narrow:

  • Leptos-first reactivity
  • binder-first architecture instead of wrapper-first architecture
  • explicit numeric motion props
  • spring, tween, and keyframe transitions
  • per-property transition overrides
  • real browser runtime on wasm32
  • clean native checking and testing outside the browser

If you are familiar with motion.dev, the mental model is similar: declare a target, and Slik animates from the current value to the new one. The difference is that v0.2 is grounded in typed Leptos NodeRef binding rather than a wrapper-centric component runtime.


What Slik is

Slik is a small, focused motion system for Leptos apps.

It is good at:

  • entry animations
  • hover and press interactions
  • fades, lifts, slides, scale, and rotation
  • per-property motion tuning
  • interruptible numeric keyframes
  • animated counters, meters, and dashboards
  • binding motion to real HTML and SVG nodes

What Slik is not

v0.2 is intentionally not trying to cover the full Motion surface area.

It does not currently include:

  • variants
  • exit / presence orchestration
  • gesture APIs
  • layout animation
  • color interpolation
  • arbitrary CSS string interpolation
  • SVG sugar components
  • a generic catch-all motion element API

That scope is intentional. v0.2 is the architectural foundation.


At a Glance

Layer Use it when Main API
Binder You want maximum control on a real node use_motion, MotionOptions
Sugar You want ergonomic motion-enabled HTML components slik::html::*
Scalar You want a single animated f64 MotionValue

Installation

If you are using Slik from this workspace or before a public crates.io release:

[dependencies]
slik = { path = "crates/slik", features = ["csr"] }
leptos = { version = "0.8", features = ["csr"] }

Slik mirrors Leptos target features:

  • csr
  • hydrate
  • ssr

Pick the same feature set you use for leptos.


Quick Start

The ergonomic starting point is usually the HTML sugar layer.

use leptos::prelude::*;
use slik::html::MotionDiv;
use slik::prelude::*;

#[component]
fn FadeInCard() -> impl IntoView {
    view! {
        <MotionDiv
            initial=Signal::derive(|| MotionStyle::new().opacity(0.0).y(24.0))
            animate=MotionStyle::new().opacity(1.0).y(0.0)
            transition=TransitionMap::new(
                Transition::tween(0.35, Easing::EaseOut).expect("valid tween"),
            )
            attr:style="padding:1rem; border-radius:14px; background:#eef2ff; border:1px solid #c7d2fe"
        >
            "Hello from Slik v0.2"
        </MotionDiv>
    }
}

This mounts with:

  • opacity: 0 -> 1
  • translateY: 24px -> 0px

using a tweened opacity + transform target on a real <div>, with no extra wrapper inserted by Slik.


The v0.2 Mental Model

Slik v0.2 is easiest to understand through four concepts.

1. MotionStyle

MotionStyle is the sparse target definition for supported motion properties.

MotionStyle::new()
    .opacity(1.0)
    .x(24.0)
    .scale(1.04)

Each property is optional. If you do not set a property, it is absent from that style snapshot.

2. Transition

Transition defines how a value moves to its target.

Slik ships with three transition families:

  • Transition::spring()
  • Transition::tween(duration_secs, easing)
  • Transition::keyframes(keyframes, duration_secs)

3. use_motion

use_motion is the canonical v0.2 API.

It binds motion to a typed Leptos NodeRef, owns the target node's inline opacity, transform, and will-change, and keeps them synced to reactive motion values.

4. MotionValue

MotionValue is the low-level scalar primitive.

Use it when you want animation without binding a full MotionStyle to a DOM node.


Binder-First Example

The binder is the foundation. Sugar components are layered on top of it.

use leptos::prelude::*;
use slik::prelude::*;

#[component]
fn BinderCard() -> impl IntoView {
    let node_ref = NodeRef::<leptos::html::Div>::new();
    let raised = RwSignal::new(false);

    let _motion = use_motion(
        node_ref,
        MotionOptions {
            initial: Some(Signal::derive(|| MotionStyle::new().opacity(0.0).y(20.0))),
            animate: Signal::derive(move || {
                if raised.get() {
                    MotionStyle::new().opacity(1.0).y(-6.0).scale(1.02)
                } else {
                    MotionStyle::new().opacity(1.0).y(0.0).scale(1.0)
                }
            }),
            transition: TransitionMap::new(Transition::spring_bouncy())
                .with(
                    MotionProp::Opacity,
                    Transition::tween(0.22, Easing::EaseOut).expect("valid tween"),
                )
                .into(),
            reduced_motion: MaybeProp::default(),
        },
    );

    view! {
        <div style="display:flex; gap:0.75rem; align-items:center">
            <button on:click=move |_| raised.update(|value| *value = !*value)>
                "Toggle"
            </button>
            <div
                node_ref=node_ref
                style="padding:1rem; border-radius:14px; background:#fef3c7; border:1px solid #f59e0b"
            >
                "Motion bound directly to this div"
            </div>
        </div>
    }
}

This is the core of the library:

  • you create a real NodeRef
  • you describe initial and animate
  • Slik manages the motion runtime
  • your element remains your element

No polymorphic wrapper, no hidden extra node.

MotionHandle

use_motion returns a MotionHandle.

That handle exposes dense per-property MotionValues:

let handle = use_motion(node_ref, options);
let x_value = handle.values.get(MotionProp::X);

This is useful when you want binder-driven DOM motion plus direct access to one or more animated scalar channels.


Thin HTML Sugar

The HTML sugar layer exists for ergonomics, not as a separate architecture.

Every motion HTML component is a thin wrapper over use_motion.

use leptos::prelude::*;
use slik::html::MotionButton;
use slik::prelude::*;

#[component]
fn HoverButton() -> impl IntoView {
    let hovered = RwSignal::new(false);

    let animate = Signal::derive(move || {
        if hovered.get() {
            MotionStyle::new().scale(1.04).y(-2.0)
        } else {
            MotionStyle::new().scale(1.0).y(0.0)
        }
    });

    view! {
        <MotionButton
            animate=animate
            transition=TransitionMap::new(Transition::spring_bouncy())
            on:mouseenter=move |_| hovered.set(true)
            on:mouseleave=move |_| hovered.set(false)
            attr:style="padding:0.8rem 1rem; border:none; border-radius:12px; background:#111827; color:white; cursor:pointer"
        >
            "Hover me"
        </MotionButton>
    }
}

Important attribute forwarding note

Motion HTML components use Leptos AttributeInterceptor.

That means:

  • event handlers such as on:click work normally
  • plain DOM attributes should be forwarded with attr:*

Examples:

<MotionDiv attr:style="padding:1rem" attr:id="hero-card">
    "..."
</MotionDiv>

Available Motion HTML Components

Current sugar coverage includes:

  • Structure: MotionArticle, MotionAside, MotionDiv, MotionFooter, MotionHeader, MotionMain, MotionNav, MotionSection
  • Headings: MotionH1, MotionH2, MotionH3, MotionH4, MotionH5, MotionH6
  • Text: MotionBlockquote, MotionCode, MotionEm, MotionP, MotionPre, MotionSmall, MotionSpan, MotionStrong
  • Lists: MotionDl, MotionLi, MotionOl, MotionUl
  • Forms and controls: MotionButton, MotionDetails, MotionFieldset, MotionForm, MotionLabel, MotionLegend, MotionSummary, MotionTextarea
  • Other content: MotionA, MotionFigcaption, MotionFigure

If you need an element that does not yet have sugar, the binder layer is already more general than the current macro list.


Supported Motion Properties

Slik currently supports these numeric motion props:

Property Meaning Units
opacity CSS opacity unitless
x horizontal translation px
y vertical translation px
scale uniform scale unitless
scale_x x-axis scale multiplier unitless
scale_y y-axis scale multiplier unitless
rotate rotation deg

Transform composition order

Transforms are composed in this fixed order:

translateX -> translateY -> scale -> rotate

Axis-specific scale multiplies the uniform scale:

effective_scale_x = scale * scale_x
effective_scale_y = scale * scale_y

So this:

MotionStyle::new().scale(1.2).scale_x(0.8)

produces:

scale(0.96, 1.2)

initial vs animate

initial

initial seeds the starting visual state for owned properties.

animate

animate is the live target snapshot.

Example:

<MotionDiv
    initial=Signal::derive(|| MotionStyle::new().opacity(0.0).y(20.0))
    animate=MotionStyle::new().opacity(1.0).y(0.0)
>
    "Fade + slide on mount"
</MotionDiv>

If initial is omitted, the first animate snapshot becomes the seed state.

That means:

  • no mount transition unless initial differs from animate
  • the binder starts from the first known target by default

Property ownership semantics

Once a property appears in either initial or animate, that property becomes owned by the binding.

In practice this means:

  • Slik keeps writing that property's current animated value
  • omitting a previously owned property from a later animate snapshot does not implicitly hand control back to other inline styles

This is deliberate. It avoids ambiguous DOM ownership after motion has claimed a property.


Reactive Targets

Reactive motion targets are a natural fit for Leptos signals and memos.

use leptos::prelude::*;
use slik::html::MotionDiv;
use slik::prelude::*;

#[component]
fn ReactiveCard() -> impl IntoView {
    let expanded = RwSignal::new(false);

    let target = Signal::derive(move || {
        if expanded.get() {
            MotionStyle::new().scale(1.04).y(-4.0)
        } else {
            MotionStyle::new().scale(1.0).y(0.0)
        }
    });

    view! {
        <div style="display:flex; gap:0.75rem; align-items:center">
            <button on:click=move |_| expanded.update(|value| *value = !*value)>
                "Toggle"
            </button>
            <MotionDiv
                animate=target
                attr:style="padding:1rem; border-radius:14px; background:#ecfccb; border:1px solid #84cc16"
            >
                "Reactive motion target"
            </MotionDiv>
        </div>
    }
}

When the signal changes, Slik retargets from the current sampled value.


Transitions

Slik has three transition families.

Spring

The default transition is a spring.

Transition::spring()

Included presets:

Transition::spring()
Transition::spring_bouncy()
Transition::spring_gentle()
Transition::spring_custom(stiffness, damping, mass)?

Use springs for:

  • hover interactions
  • press feedback
  • panels and cards
  • UI motion that should feel responsive and physical

spring_custom is validated and returns Result<Transition, TransitionError>.

Tween

Tweens run for a fixed duration with cubic-bezier easing.

let tween = Transition::tween(0.35, Easing::EaseInOut)?;

Available easings:

Easing::Linear
Easing::Ease
Easing::EaseIn
Easing::EaseOut
Easing::EaseInOut
Easing::Snappy
Easing::Custom(x1, y1, x2, y2)

Use tweens for:

  • fades
  • deterministic micro-interactions
  • motion where a fixed duration matters more than spring feel

tween is validated and returns Result<Transition, TransitionError>.

Keyframes

Keyframes are literal numeric sequences over normalized progress from 0.0 to 1.0.

Each keyframe has:

  • an offset
  • a value
  • an easing used for the segment that ends at that keyframe

Helpers:

Keyframe::current(offset)
Keyframe::absolute(offset, value)
Keyframe::target(offset)

Example:

let pulse = Transition::keyframes(
    vec![
        Keyframe::current(0.0),
        Keyframe::absolute(0.35, 1.24).ease(Easing::EaseOut),
        Keyframe::absolute(0.7, 1.06).ease(Easing::EaseInOut),
        Keyframe::target(1.0).ease(Easing::EaseOut),
    ],
    0.55,
)?;

This means:

  • start from the live current value
  • overshoot to 1.24
  • settle toward 1.06
  • finish at the requested target

Keyframe validation rules

A keyframe transition must satisfy all of these:

  • at least 2 keyframes
  • first offset must be 0.0
  • last offset must be 1.0
  • offsets must strictly increase
  • absolute values must be finite
  • final keyframe must be Keyframe::target(1.0)

That final target requirement is intentional. It keeps retargeting semantics explicit and interruptible.


Per-Property Transitions

Use TransitionMap when different properties should move differently.

let transitions = TransitionMap::new(Transition::spring_bouncy())
    .with(
        MotionProp::Opacity,
        Transition::tween(0.25, Easing::EaseOut).expect("valid tween"),
    )
    .with(
        MotionProp::Rotate,
        Transition::tween(0.5, Easing::Snappy).expect("valid tween"),
    );

This lets you do things like:

  • spring translation
  • tween opacity
  • snap rotation differently from position

Example:

use leptos::prelude::*;
use slik::html::MotionDiv;
use slik::prelude::*;

#[component]
fn MixedTransitions() -> impl IntoView {
    let expanded = RwSignal::new(false);

    let animate = Signal::derive(move || {
        if expanded.get() {
            MotionStyle::new().scale(1.25).rotate(180.0).opacity(0.7)
        } else {
            MotionStyle::new().scale(1.0).rotate(0.0).opacity(1.0)
        }
    });

    let transitions = TransitionMap::new(Transition::spring_bouncy())
        .with(
            MotionProp::Opacity,
            Transition::tween(0.35, Easing::EaseOut).expect("valid tween"),
        )
        .with(
            MotionProp::Rotate,
            Transition::tween(0.55, Easing::Snappy).expect("valid tween"),
        );

    view! {
        <div style="display:flex; gap:0.75rem; align-items:center">
            <button on:click=move |_| expanded.update(|value| *value = !*value)>
                "Transform"
            </button>
            <MotionDiv
                animate=animate
                transition=transitions
                attr:style="width:60px; height:60px; background:#0f766e; border-radius:10px"
            />
        </div>
    }
}

Reduced Motion

Slik exposes both system-level reduced-motion state and per-binding policy.

Browser preference signal

let prefers_reduced_motion = use_reduced_motion();

This mirrors the browser's prefers-reduced-motion media query.

Binding policy

ReducedMotionConfig controls how a specific motion binding responds:

  • ReducedMotionConfig::Auto
  • ReducedMotionConfig::Always
  • ReducedMotionConfig::Never

Example:

<MotionDiv
    animate=target
    transition=TransitionMap::new(Transition::spring_bouncy())
    reduced_motion=ReducedMotionConfig::Always
    attr:style="width:48px; height:48px; background:#111827; border-radius:12px"
/>

Always forces immediate jumps. Auto follows the browser preference. Never ignores it.


Low-Level API: MotionValue

MotionValue animates a single f64.

Use it when:

  • you want animated numeric state without DOM binding
  • you want to drive text, counters, or readouts
  • the binder layer is too high-level for the job

Example:

use leptos::prelude::*;
use slik::prelude::*;

#[component]
fn AnimatedCounter() -> impl IntoView {
    let count = RwSignal::new(0.0_f64);
    let display = MotionValue::new(0.0, Transition::spring());

    Effect::new(move |_| {
        display.set_target(count.get());
    });

    view! {
        <div style="display:flex; gap:0.75rem; align-items:center">
            <button on:click=move |_| count.update(|value| *value -= 1.0)>"-"</button>
            <span>{move || format!("{:.1}", display.get())}</span>
            <button on:click=move |_| count.update(|value| *value += 1.0)>"+"</button>
        </div>
    }
}

MotionValue methods

MotionValue::new(initial, transition)
value.get()
value.get_untracked()
value.target()
value.is_animating()
value.is_animating_untracked()
value.set_target(next)
value.jump(next)
value.stop()
value.signal()

Semantics

  • set_target(v) animates from the current sampled value toward v
  • spring retargeting preserves spring momentum
  • tween and keyframe retargeting restart from the current sampled value
  • jump(v) snaps immediately and clears active animation
  • stop() halts animation at the current sampled value

DOM Behavior

No wrapper node

This is the most important v0.2 shift.

Slik no longer centers the library around a dedicated wrapper component. The binder targets real nodes directly, and the HTML sugar components simply create those nodes for you.

HTML and SVG binding

use_motion supports style-capable HTML and SVG nodes.

That means:

  • HTML binding is covered by both use_motion and slik::html::*
  • SVG binding is available through the binder layer today
  • HTML sugar is explicit; SVG sugar does not exist yet

DOM properties owned by Slik

The binder writes:

  • opacity
  • transform
  • will-change

will-change is only emitted for actively animating groups:

  • opacity
  • transform

It is not left on permanently after animation settles.


Browser and Native Runtime Behavior

Slik is designed to be pleasant in a mixed Rust + wasm workflow.

In practice:

  • the real animation loop runs in the browser on wasm32
  • the browser runtime is driven by requestAnimationFrame
  • native cargo check, cargo test, and workspace builds remain straightforward
  • on non-wasm targets, animation requests snap immediately to the latest target

This is deliberate. It keeps the browser behavior correct without making native tooling awkward.


API Overview

slik::prelude::*

The prelude re-exports the main working surface:

Item Purpose
use_motion binder-first motion hook
use_reduced_motion browser reduced-motion signal
MotionOptions binder configuration
MotionHandle handle returned by use_motion
MotionValues dense per-property motion values
ReducedMotionConfig per-binding reduced-motion policy
MotionStyle sparse motion target definition
MotionProp explicit property enum
MotionValue low-level animated scalar
Transition spring, tween, keyframe families
TransitionMap default + per-property transitions
TransitionError spring/tween validation errors
Easing easing presets and custom bezier
Keyframe keyframe builder
KeyframeValue current / absolute / target keyframe values
KeyframeTransition validated keyframe sequence
KeyframeError keyframe validation errors

slik::html

Use this module when you want motion-enabled HTML components such as:

  • MotionDiv
  • MotionButton
  • MotionSection
  • MotionMain
  • MotionP
  • MotionH1 through MotionH6

Current Limitations

The current scope is intentionally lean.

Notable constraints today:

  • numeric properties only
  • no layout animation
  • no color interpolation
  • no arbitrary CSS string interpolation
  • no exit / presence orchestration
  • no variants
  • no SVG sugar components
  • sugar coverage is explicit, not open-ended

These are reasonable next-step candidates, but they are not required to use the current v0.2 foundation effectively.


Design Choices in v0.2

These choices are deliberate.

Binder-first, sugar-second

use_motion is the architectural center.

The sugar layer exists because ergonomics matter, but it is intentionally a thin layer over the binder instead of a competing runtime.

Numeric-only motion surface

All current motion values are f64.

That keeps interpolation simple, predictable, and easy to test.

Explicit property enum

Per-property overrides use MotionProp, not string keys.

That avoids typo-driven configuration mistakes.

Keyframe target is explicit

The final keyframe must target KeyframeValue::Target.

That keeps interrupt and retarget behavior coherent.


Showcase

The repository includes a working showcase in:

examples/showcase/src/main.rs

It demonstrates:

  • binder-first entry animation
  • HTML sugar components
  • tween targets
  • keyframe pulse
  • MotionValue counters
  • per-property overrides
  • reduced-motion policy
  • an interactive MotionButton

Status

Slik v0.2 is the first real public-facing foundation for the crate.

It is intentionally narrow, but the pieces that are present are meant to be coherent:

  • typed DOM binding
  • thin ergonomic sugar
  • explicit motion properties
  • predictable numeric interpolation
  • validated transition inputs
  • browser-aware reduced-motion handling
  • a low-level primitive when the binder layer is too high-level

If you understand this README, you understand the current shape of the crate.


License and Contributions

MIT.

Contributions are currently not being actively solicited while the roadmap from v0.2 toward v1.0 is still being shaped.

Issues, forks, and local experimentation are welcome.