Skip to content

Spring

Springs describe motion in terms of physics — stiffness, damping, mass — rather than a fixed time-and-curve. There is no explicit duration: the motion ends when the value has settled at the target.

Use a spring when motion should feel physical. Use an easing when you want a known duration and a predictable curve.

Examples

The same horizontal translate, driven by all five spring presets and rendered in each of the four time-driven engines. Click a preset to play the animation; each click flips the direction so you can keep tapping to compare. Notice there's no duration or speed — settle time is determined entirely by the spring's physics and the distance it has to travel.

The bottom row builds a Spring.custom from the three inputs. Edit any of stiffness, damping, or mass and click Play custom to feel the change. Lower damping bounces longer; higher stiffness snaps faster; heavier mass feels more sluggish.

View Example

Behaviour: The full spring representation is baked into the @keyframes rules.

Behaviour: The Spring is interpolated on every frame.

Behaviour: The spring is baked into dense keyframes and handed to the Web Animations API for playback.

View Source Code
module Animation.Keyframe.Springs.Main exposing (main)

import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.Keyframe as Keyframe
import Anim.Property.Translate as Translate
import Browser
import Html exposing (Html, button, div, input, label, text)
import Html.Attributes exposing (class, step, style, type_, value)
import Html.Events exposing (onClick, onInput)
import Motion.Spring as Spring exposing (Spring)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type alias Model =
    { animState : Keyframe.AnimState
    , selected : String
    , atEnd : Bool
    , stiffness : String
    , damping : String
    , mass : String
    }


animGroup : String
animGroup =
    "springBox"


init : ( Model, Cmd Msg )
init =
    ( { animState =
            Keyframe.init
                [ Translate.initX animGroup 0 ]
      , selected = "gentle"
      , atEnd = False
      , stiffness = "120"
      , damping = "14"
      , mass = "1"
      }
    , Cmd.none
    )



-- ANIMATION


animateTo : Float -> Spring -> AnimBuilder { eng | withTiming : (), withSpring : () } -> AnimBuilder { eng | withTiming : (), withSpring : () }
animateTo x spring =
    Translate.begin
        >> Translate.toX x
        >> Translate.spring spring
        >> Translate.end



-- UPDATE


type Msg
    = Play String Spring
    | PlayCustom
    | SetStiffness String
    | SetDamping String
    | SetMass String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Play label spring ->
            let
                target =
                    if model.atEnd then
                        0

                    else
                        420
            in
            ( { model
                | animState =
                    Keyframe.animate model.animState <|
                        Keyframe.for animGroup
                            >> animateTo target spring
                , selected = label
                , atEnd = not model.atEnd
              }
            , Cmd.none
            )

        PlayCustom ->
            let
                stiffness =
                    parseWithMin 1 model.stiffness

                damping =
                    parseWithMin 0 model.damping

                mass =
                    parseWithMin 0.1 model.mass

                spring =
                    Spring.custom
                        { stiffness = stiffness
                        , damping = damping
                        , mass = mass
                        }

                target =
                    if model.atEnd then
                        0

                    else
                        420
            in
            ( { model
                | animState =
                    Keyframe.animate model.animState <|
                        Keyframe.for animGroup
                            >> animateTo target spring
                , selected = "custom"
                , atEnd = not model.atEnd
                , stiffness = String.fromFloat stiffness
                , damping = String.fromFloat damping
                , mass = String.fromFloat mass
              }
            , Cmd.none
            )

        SetStiffness str ->
            ( { model | stiffness = str }, Cmd.none )

        SetDamping str ->
            ( { model | damping = str }, Cmd.none )

        SetMass str ->
            ( { model | mass = str }, Cmd.none )


parseWithMin : Float -> String -> Float
parseWithMin minValue str =
    String.toFloat str
        |> Maybe.withDefault minValue
        |> max minValue



-- VIEW


presets : List ( String, Spring )
presets =
    [ ( "noWobble", Spring.noWobble )
    , ( "gentle", Spring.gentle )
    , ( "wobbly", Spring.wobbly )
    , ( "stiff", Spring.stiff )
    , ( "slow", Spring.slow )
    ]


view : Model -> Html Msg
view model =
    div
        [ class "example-stage"
        , style "height" "auto"
        , style "min-height" "260px"
        ]
        [ Keyframe.styleNode model.animState
        , div [ class "example-controls" ]
            (List.map (presetButton model.selected) presets)
        , customControls model
        , div
            [ style "width" "100%"
            , style "max-width" "480px"
            , style "height" "70px"
            , style "display" "flex"
            , style "align-items" "center"
            ]
            [ div
                (Keyframe.attributes animGroup model.animState
                    ++ [ style "width" "60px"
                       , style "height" "60px"
                       , style "background-color" "#3498db"
                       , style "border-radius" "8px"
                       ]
                )
                []
            ]
        ]


customControls : Model -> Html Msg
customControls model =
    let
        playVariant =
            if model.selected == "custom" then
                "primary"

            else
                "purple"
    in
    div
        [ class "example-controls"
        , style "gap" "12px"
        ]
        [ numberInput "Stiffness" "1" model.stiffness SetStiffness
        , numberInput "Damping" "1" model.damping SetDamping
        , numberInput "Mass" "0.1" model.mass SetMass
        , button
            [ onClick PlayCustom
            , class ("ui-action-button " ++ playVariant)
            ]
            [ text "Play custom" ]
        ]


numberInput : String -> String -> String -> (String -> Msg) -> Html Msg
numberInput labelText stepSize current toMsg =
    label
        [ style "display" "flex"
        , style "flex-direction" "column"
        , style "font-size" "12px"
        , style "font-weight" "500"
        , style "color" "#4a5568"
        , style "gap" "4px"
        ]
        [ text labelText
        , input
            [ type_ "number"
            , step stepSize
            , value current
            , onInput toMsg
            , style "width" "80px"
            , style "padding" "6px 8px"
            , style "border" "1px solid #cbd5e0"
            , style "border-radius" "6px"
            , style "font-size" "14px"
            , style "font-family" "inherit"
            ]
            []
        ]


presetButton : String -> ( String, Spring ) -> Html Msg
presetButton selected ( label, spring ) =
    let
        variant =
            if label == selected then
                "primary"

            else
                "purple"
    in
    button
        [ onClick (Play label spring)
        , class ("ui-action-button " ++ variant)
        ]
        [ text label ]
module Animation.Sub.Springs.Main exposing (main)

import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.Sub as Sub
import Anim.Property.Translate as Translate
import Browser
import Html exposing (Html, button, div, input, label, text)
import Html.Attributes exposing (class, step, style, type_, value)
import Html.Events exposing (onClick, onInput)
import Motion.Spring as Spring exposing (Spring)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }



-- MODEL


type alias Model =
    { animState : Sub.AnimState
    , selected : String
    , atEnd : Bool
    , stiffness : String
    , damping : String
    , mass : String
    }


animGroup : String
animGroup =
    "springBox"


init : ( Model, Cmd Msg )
init =
    ( { animState =
            Sub.init
                [ Translate.initX animGroup 0 ]
      , selected = "gentle"
      , atEnd = False
      , stiffness = "120"
      , damping = "14"
      , mass = "1"
      }
    , Cmd.none
    )



-- ANIMATION


animateTo : Float -> Spring -> AnimBuilder { eng | withTiming : (), withSpring : () } -> AnimBuilder { eng | withTiming : (), withSpring : () }
animateTo x spring =
    Translate.begin
        >> Translate.toX x
        >> Translate.spring spring
        >> Translate.end



-- UPDATE


type Msg
    = GotSubMsg Sub.AnimMsg
    | Play String Spring
    | PlayCustom
    | SetStiffness String
    | SetDamping String
    | SetMass String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotSubMsg subMsg ->
            let
                ( newAnimState, _ ) =
                    Sub.update subMsg model.animState
            in
            ( { model | animState = newAnimState }
            , Cmd.none
            )

        Play label spring ->
            let
                target =
                    if model.atEnd then
                        0

                    else
                        420
            in
            ( { model
                | animState =
                    Sub.animate model.animState <|
                        Sub.for animGroup
                            >> animateTo target spring
                , selected = label
                , atEnd = not model.atEnd
              }
            , Cmd.none
            )

        PlayCustom ->
            let
                stiffness =
                    parseWithMin 1 model.stiffness

                damping =
                    parseWithMin 0 model.damping

                mass =
                    parseWithMin 0.1 model.mass

                spring =
                    Spring.custom
                        { stiffness = stiffness
                        , damping = damping
                        , mass = mass
                        }

                target =
                    if model.atEnd then
                        0

                    else
                        420
            in
            ( { model
                | animState =
                    Sub.animate model.animState <|
                        Sub.for animGroup
                            >> animateTo target spring
                , selected = "custom"
                , atEnd = not model.atEnd
                , stiffness = String.fromFloat stiffness
                , damping = String.fromFloat damping
                , mass = String.fromFloat mass
              }
            , Cmd.none
            )

        SetStiffness str ->
            ( { model | stiffness = str }, Cmd.none )

        SetDamping str ->
            ( { model | damping = str }, Cmd.none )

        SetMass str ->
            ( { model | mass = str }, Cmd.none )


parseWithMin : Float -> String -> Float
parseWithMin minValue str =
    String.toFloat str
        |> Maybe.withDefault minValue
        |> max minValue



-- SUBSCRIPTIONS


subscriptions : Model -> Sub.Sub Msg
subscriptions model =
    Sub.subscriptions GotSubMsg model.animState



-- VIEW


presets : List ( String, Spring )
presets =
    [ ( "noWobble", Spring.noWobble )
    , ( "gentle", Spring.gentle )
    , ( "wobbly", Spring.wobbly )
    , ( "stiff", Spring.stiff )
    , ( "slow", Spring.slow )
    ]


view : Model -> Html Msg
view model =
    div
        [ class "example-stage"
        , style "height" "auto"
        , style "min-height" "260px"
        ]
        [ div [ class "example-controls" ]
            (List.map (presetButton model.selected) presets)
        , customControls model
        , div
            [ style "width" "100%"
            , style "max-width" "480px"
            , style "height" "70px"
            , style "display" "flex"
            , style "align-items" "center"
            ]
            [ div
                (Sub.attributes animGroup model.animState
                    ++ [ style "width" "60px"
                       , style "height" "60px"
                       , style "background-color" "#3498db"
                       , style "border-radius" "8px"
                       ]
                )
                []
            ]
        ]


customControls : Model -> Html Msg
customControls model =
    let
        playVariant =
            if model.selected == "custom" then
                "primary"

            else
                "purple"
    in
    div
        [ class "example-controls"
        , style "gap" "12px"
        ]
        [ numberInput "Stiffness" "1" model.stiffness SetStiffness
        , numberInput "Damping" "1" model.damping SetDamping
        , numberInput "Mass" "0.1" model.mass SetMass
        , button
            [ onClick PlayCustom
            , class ("ui-action-button " ++ playVariant)
            ]
            [ text "Play custom" ]
        ]


numberInput : String -> String -> String -> (String -> Msg) -> Html Msg
numberInput labelText stepSize current toMsg =
    label
        [ style "display" "flex"
        , style "flex-direction" "column"
        , style "font-size" "12px"
        , style "font-weight" "500"
        , style "color" "#4a5568"
        , style "gap" "4px"
        ]
        [ text labelText
        , input
            [ type_ "number"
            , step stepSize
            , value current
            , onInput toMsg
            , style "width" "80px"
            , style "padding" "6px 8px"
            , style "border" "1px solid #cbd5e0"
            , style "border-radius" "6px"
            , style "font-size" "14px"
            , style "font-family" "inherit"
            ]
            []
        ]


presetButton : String -> ( String, Spring ) -> Html Msg
presetButton selected ( label, spring ) =
    let
        variant =
            if label == selected then
                "primary"

            else
                "purple"
    in
    button
        [ onClick (Play label spring)
        , class ("ui-action-button " ++ variant)
        ]
        [ text label ]
port module Animation.WAAPI.Springs.Main exposing (main)

import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.WAAPI as WAAPI
import Anim.Property.Translate as Translate
import Browser
import Html exposing (Html, button, div, input, label, text)
import Html.Attributes exposing (class, step, style, type_, value)
import Html.Events exposing (onClick, onInput)
import Json.Encode as Encode
import Motion.Spring as Spring exposing (Spring)



-- PORTS


port motionCmd : Encode.Value -> Cmd msg


port motionMsg : (Encode.Value -> msg) -> Sub msg



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }



-- MODEL


type alias Model =
    { animState : WAAPI.AnimState Msg
    , selected : String
    , atEnd : Bool
    , stiffness : String
    , damping : String
    , mass : String
    }


animGroup : String
animGroup =
    "springBox"


init : ( Model, Cmd Msg )
init =
    ( { animState =
            WAAPI.init motionCmd motionMsg <|
                [ Translate.initX animGroup 0 ]
      , selected = "gentle"
      , atEnd = False
      , stiffness = "120"
      , damping = "14"
      , mass = "1"
      }
    , Cmd.none
    )



-- ANIMATION


animateTo : Float -> Spring -> AnimBuilder { eng | withTiming : (), withSpring : () } -> AnimBuilder { eng | withTiming : (), withSpring : () }
animateTo x spring =
    Translate.begin
        >> Translate.toX x
        >> Translate.spring spring
        >> Translate.end



-- UPDATE


type Msg
    = GotWaapiMsg WAAPI.AnimMsg
    | Play String Spring
    | PlayCustom
    | SetStiffness String
    | SetDamping String
    | SetMass String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotWaapiMsg waapiMsg ->
            let
                ( newAnimState, _ ) =
                    WAAPI.update waapiMsg model.animState
            in
            ( { model | animState = newAnimState }
            , Cmd.none
            )

        Play label spring ->
            let
                target =
                    if model.atEnd then
                        0

                    else
                        420

                ( newAnimState, cmd ) =
                    WAAPI.animate model.animState <|
                        WAAPI.for animGroup
                            >> animateTo target spring
            in
            ( { model
                | animState = newAnimState
                , selected = label
                , atEnd = not model.atEnd
              }
            , cmd
            )

        PlayCustom ->
            let
                stiffness =
                    parseWithMin 1 model.stiffness

                damping =
                    parseWithMin 0 model.damping

                mass =
                    parseWithMin 0.1 model.mass

                spring =
                    Spring.custom
                        { stiffness = stiffness
                        , damping = damping
                        , mass = mass
                        }

                target =
                    if model.atEnd then
                        0

                    else
                        420

                ( newAnimState, cmd ) =
                    WAAPI.animate model.animState <|
                        WAAPI.for animGroup
                            >> animateTo target spring
            in
            ( { model
                | animState = newAnimState
                , selected = "custom"
                , atEnd = not model.atEnd
                , stiffness = String.fromFloat stiffness
                , damping = String.fromFloat damping
                , mass = String.fromFloat mass
              }
            , cmd
            )

        SetStiffness str ->
            ( { model | stiffness = str }, Cmd.none )

        SetDamping str ->
            ( { model | damping = str }, Cmd.none )

        SetMass str ->
            ( { model | mass = str }, Cmd.none )


parseWithMin : Float -> String -> Float
parseWithMin minValue str =
    String.toFloat str
        |> Maybe.withDefault minValue
        |> max minValue



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions model =
    WAAPI.subscriptions GotWaapiMsg model.animState



-- VIEW


presets : List ( String, Spring )
presets =
    [ ( "noWobble", Spring.noWobble )
    , ( "gentle", Spring.gentle )
    , ( "wobbly", Spring.wobbly )
    , ( "stiff", Spring.stiff )
    , ( "slow", Spring.slow )
    ]


view : Model -> Html Msg
view model =
    div
        [ class "example-stage"
        , style "height" "auto"
        , style "min-height" "260px"
        ]
        [ div [ class "example-controls" ]
            (List.map (presetButton model.selected) presets)
        , customControls model
        , div
            [ style "width" "100%"
            , style "max-width" "480px"
            , style "height" "70px"
            , style "display" "flex"
            , style "align-items" "center"
            ]
            [ div
                (WAAPI.attributes animGroup model.animState
                    ++ [ style "width" "60px"
                       , style "height" "60px"
                       , style "background-color" "#3498db"
                       , style "border-radius" "8px"
                       ]
                )
                []
            ]
        ]


customControls : Model -> Html Msg
customControls model =
    let
        playVariant =
            if model.selected == "custom" then
                "primary"

            else
                "purple"
    in
    div
        [ class "example-controls"
        , style "gap" "12px"
        ]
        [ numberInput "Stiffness" "1" model.stiffness SetStiffness
        , numberInput "Damping" "1" model.damping SetDamping
        , numberInput "Mass" "0.1" model.mass SetMass
        , button
            [ onClick PlayCustom
            , class ("ui-action-button " ++ playVariant)
            ]
            [ text "Play custom" ]
        ]


numberInput : String -> String -> String -> (String -> Msg) -> Html Msg
numberInput labelText stepSize current toMsg =
    label
        [ style "display" "flex"
        , style "flex-direction" "column"
        , style "font-size" "12px"
        , style "font-weight" "500"
        , style "color" "#4a5568"
        , style "gap" "4px"
        ]
        [ text labelText
        , input
            [ type_ "number"
            , step stepSize
            , value current
            , onInput toMsg
            , style "width" "80px"
            , style "padding" "6px 8px"
            , style "border" "1px solid #cbd5e0"
            , style "border-radius" "6px"
            , style "font-size" "14px"
            , style "font-family" "inherit"
            ]
            []
        ]


presetButton : String -> ( String, Spring ) -> Html Msg
presetButton selected ( label, spring ) =
    let
        variant =
            if label == selected then
                "primary"

            else
                "purple"
    in
    button
        [ onClick (Play label spring)
        , class ("ui-action-button " ++ variant)
        ]
        [ text label ]

Spring Presets

Springs describe physical motion — stiffness, damping, mass — rather than time-and-curve. Their duration is emergent: the motion ends when the value has settled at the target. This makes springs the right primitive for anything that should feel physical: bouncy reveals, gesture-handoff momentum, anything where a tween's fixed-duration ramp would feel artificial.

All presets live in Motion.Spring.

gentle

A soft, slow settle. Mild overshoot, takes its time.

Good for: hero-element reveals, large modals, anything where a sense of weight is welcome.

wobbly

Lively and bouncy. Several visible oscillations before settling.

Good for: playful UI accents, attention-grabbing reveals, cartoony interactions.

stiff

Snappy and direct. Small overshoot, settles quickly.

Good for: button presses, tooltip reveals, anything that should feel crisp and immediate.

slow

Low stiffness with extra damping — a long, mellow approach.

Good for: ambient motion, slow-developing reveals, drifting-into-place effects.

noWobble

Critically damped — the fastest approach to the target with no overshoot whatsoever.

Good for: when you want spring-like timing but tween-like absence of overshoot. Useful for scroll handoffs and other places where bounce would look like a bug.

Custom

Hand-tune a spring's physics via Motion.Spring.custom:

Motion.Spring.custom
    { stiffness = 220   -- Hooke's-law k. Higher = snappier. Typical 100..400.
    , damping = 16      -- viscous friction. Higher = less wobbly. Typical 10..40.
    , mass = 1          -- oscillator mass. Heavier = more sluggish. Typical 1.0.
    }

The damping ratio c / (2·√(k·m)) decides the regime:

Ratio Regime Feel
< 1 Under-damped Oscillates and decays
= 1 Critically damped Fastest no-overshoot settle
> 1 Over-damped Slow no-overshoot approach

Inputs are clamped: stiffness and damping below 0 become 0; mass below 1e-6 becomes 1e-6.

Choosing a Spring

Use Case Recommended Preset Why
Hero reveals, large modals gentle Soft settle with a sense of weight
Playful accents, attention-grabbers wobbly Visible oscillations — character and energy
Buttons, tooltips, snappy state changes stiff Quick to settle, small overshoot
Ambient drift, slow-developing reveals slow Mellow approach, no urgency
Scroll handoffs, anywhere overshoot would read as a bug noWobble Spring-like timing without bounce

Springs vs easings

A spring's duration depends on its physics and the distance it has to travel — short hops settle quickly, long ones take longer, all using the same configuration. Easings always take their configured duration, regardless of distance. Reach for a spring when distance is dynamic or unknown.

Engines that support springs

Keyframe, Sub, WAAPI, Scroll Timeline, View Timeline.

The Keyframe engine and the JS-backed engines (WAAPI, Scroll Timeline, View Timeline) pre-bake the spring into densely-spaced samples. Sub renders the analytic solution every frame.

Springs and duration / speed

Setting a spring overrides any easing already on the builder, and the spring's settle time is what determines how long the motion lasts. duration and speed only apply to easing-based motion.

Setting a Spring

A spring can be set at either level, with the same precedence rules as easing:

  • Engine-level (Engine.spring) — default for every property in the builder.
  • Property-level (Property.spring) — overrides the engine default for that property only.

Next Steps

Continue to transform composition.

Transform Order →