Skip to content

CSS Transition Engine

This page is a practical guide to using the Transition engine. Read Engines Overview when you want side-by-side comparisons and tradeoffs.

This Engine builds native browser CSS transitions for simple A→B property animations. The browser handles all rendering, providing excellent performance with minimal setup.

Example

Simple A→B button hover animations.

View Example

View Source Code
module Animation.Transition.ButtonHovers.Main exposing (main)

import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.Transition as Transition
import Anim.Extra.View3D as View3D
import Anim.Property.Scale as Scale
import Anim.Property.Size as Size
import Anim.Property.Translate as Translate
import Anim.Unit exposing (Unit(..))
import Browser
import Html exposing (Html, div, text)
import Html.Attributes exposing (class, style)
import Html.Events.Extra.Pointer as Pointer
import Motion.Easing exposing (Easing(..))



-- MAIN


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



-- MODEL


type alias Model =
    { animState : Transition.AnimState }


init : ( Model, Cmd Msg )
init =
    ( { animState =
            Transition.init
                [ Size.initHW scaleButton baseHeight baseWidth >> Size.cssUnit Cqmin
                , Size.initHW sizeButton baseHeight baseWidth >> Size.cssUnit Cqmin
                , Size.initHW zButton baseHeight baseWidth >> Size.cssUnit Cqmin
                ]
      }
    , Cmd.none
    )



-- ANIMATIONS
-- Avoid typos from hardcoding strings in multiple places


scaleButton : String
scaleButton =
    "scaleButton"


sizeButton : String
sizeButton =
    "sizeButton"


zButton : String
zButton =
    "zButton"


baseWidth : Float
baseWidth =
    51


baseHeight : Float
baseHeight =
    15.8


hoverWidth : Float
hoverWidth =
    60


hoverHeight : Float
hoverHeight =
    20


hoverDuration : Int
hoverDuration =
    200


hoverEasing : Easing
hoverEasing =
    CubicOut


unhoverEasing : Easing
unhoverEasing =
    CubicIn





scaleUp : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
scaleUp =
    Scale.begin
        >> Scale.to 1.1
        >> Scale.duration hoverDuration
        >> Scale.easing hoverEasing
        >> Scale.end


scaleDown : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
scaleDown =
    Scale.begin
        >> Scale.to 1
        >> Scale.duration hoverDuration
        >> Scale.easing unhoverEasing
        >> Scale.end


growSize : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
growSize =
    Size.begin
        >> Size.toHW hoverHeight hoverWidth
        >> Size.duration hoverDuration
        >> Size.easing hoverEasing
        >> Size.end


shrinkSize : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
shrinkSize =
    Size.begin
        >> Size.toHW baseHeight baseWidth
        >> Size.duration hoverDuration
        >> Size.easing unhoverEasing
        >> Size.end


liftUp : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
liftUp =
    Translate.begin
        >> Translate.toZ 60
        >> Translate.duration hoverDuration
        >> Translate.easing hoverEasing
        >> Translate.end


setDown : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
setDown =
    Translate.begin
        >> Translate.toZ 0
        >> Translate.duration hoverDuration
        >> Translate.easing unhoverEasing
        >> Translate.end



-- UPDATE


type Msg
    = ScaleHover
    | ScaleUnhover
    | SizeHover
    | SizeUnhover
    | ZHover
    | ZUnhover


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScaleHover ->
            ( { model
                | animState =
                    Transition.animate model.animState <|
                        Transition.for scaleButton
                            >> scaleUp
              }
            , Cmd.none
            )

        ScaleUnhover ->
            ( { model
                | animState =
                    Transition.animate model.animState <|
                        Transition.for scaleButton
                            >> scaleDown
              }
            , Cmd.none
            )

        SizeHover ->
            ( { model
                | animState =
                    Transition.animate model.animState <|
                        Transition.for sizeButton
                            >> growSize
              }
            , Cmd.none
            )

        SizeUnhover ->
            ( { model
                | animState =
                    Transition.animate model.animState <|
                        Transition.for sizeButton
                            >> shrinkSize
              }
            , Cmd.none
            )

        ZHover ->
            ( { model
                | animState =
                    Transition.animate model.animState <|
                        Transition.for zButton
                            >> liftUp
              }
            , Cmd.none
            )

        ZUnhover ->
            ( { model
                | animState =
                    Transition.animate model.animState <|
                        Transition.for zButton
                            >> setDown
              }
            , Cmd.none
            )



-- VIEW


view : Model -> Html Msg
view model =
    div
        [ class "example-stage"
        , style "container-type" "size"
        ]
        [ div
            [ style "padding" "7px"
            , style "border-radius" "12px"
            , style "border" "2px solid #041e53"
            , style "justify-content" "center"
            , style "gap" "clamp(12px, 3vmin, 24px)"
            , style "display" "flex"
            , style "flex-direction" "column"
            , style "align-items" "center"
            ]
            [ button "Scale" ScaleHover ScaleUnhover scaleButton model.animState
            , button "Size" SizeHover SizeUnhover sizeButton model.animState
            , div
                [ View3D.perspective 600 ]
                [ button "Translate Z" ZHover ZUnhover zButton model.animState ]
            ]
        ]





button : String -> Msg -> Msg -> String -> Transition.AnimState -> Html Msg
button label hoverMsg unhoverMsg groupName animState =
    div
        (Transition.attributes groupName animState
            ++ [ Pointer.onEnter (\_ -> hoverMsg)
               , Pointer.onLeave (\_ -> unhoverMsg)
               , style "display" "flex"
               , style "align-items" "center"
               , style "justify-content" "center"
               , style "background-color" "#3b82f6"
               , style "color" "white"
               , style "font-size" "clamp(14px, 3.5cqw, 26px)"
               , style "font-weight" "600"
               , style "padding" "0 clamp(8px, 2.2cqmin, 16px)"
               , style "border-radius" "8px"
               , style "cursor" "pointer"
               , style "touch-action" "manipulation"
               , style "-webkit-tap-highlight-color" "transparent"
               , style "user-select" "none"
               , style "box-sizing" "border-box"
               , style "box-shadow" "0 3px 5px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.4)"
               ]
        )
        [ text label ]

Quick Walkthrough

Here's a general workflow to get up an running quickly.

1. Build

View Source Code
import Anim.Engine.Transition as Transition
import Anim.Property.Opacity as Opacity


fadeIn : Transition.AnimBuilder eng -> Transition.AnimBuilder eng
fadeIn =
    Opacity.begin
        >> Opacity.to 1
        >> Opacity.end

2. Initialize

View Source Code
type alias Model =
    { animState : Transition.AnimState }


init : ( Model, Cmd Msg )
init =
    ( { animState = Transition.init [ Opacity.init "card" 0 ] }
    , Cmd.none
    )

3. Render

Render both engine attributes and event listeners on the animated element.

View Source Code
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick TriggerFadeIn ] [ text "Fade In" ]
        , div
            (Transition.attributes "card" model.animState
                ++ Transition.events GotAnimMsg
            )
            [ text "Animated card" ]
        ]

4. Trigger with animate

Call animate to apply the animation config to the current AnimState.

View Source Code
TriggerFadeIn ->
    ( { model | animState = 
        Transition.animate model.animState <|
            Transition.for "card" >> fadeIn 
      }
    , Cmd.none
    )

5. React

Use update for incoming Transition events.

View Source Code
type Msg
    = TriggerFadeIn
    | GotAnimMsg Transition.AnimMsg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotAnimMsg animMsg ->
            let
                ( animState, maybeEvent ) =
                    Transition.update animMsg model.animState
            in
            handleAnimEvent maybeEvent { model | animState = animState }

        _ ->
            (model, Cmd.none)


handleAnimEvent : Maybe Transition.AnimEvent -> Model -> ( Model, Cmd Msg )
handleAnimEvent maybeEvent model =
    case maybeEvent of
        Just (Transition.Ended _ _ "card") ->
            ( model, Cmd.none )

        _ ->
            ( model, Cmd.none )

In Detail

Initialize

Pass a list of property initializers to init. Each registers an animation group name and sets the element's starting inline style from the first render.

View Source Code
init : ( Model, Cmd Msg )
init =
    ( { animState = Transition.init [ Opacity.init "card" 0 ] }
    , Cmd.none
    )

📖 See Initialize for more info.

Trigger

Call animate to apply an animation to the current AnimState. The browser transitions from its current computed style to the values provided.

Starting values in the builder config are ignored — the browser always starts CSS Transitions from the element's current computed style, and the Engine cannot override this.

View Source Code
ShowCard ->
    ( { animState = Transition.animate model.animState cardFadeIn }
    , Cmd.none
    )

📖 See Triggering Animations for more info.

Mid-Flight Interruptions

Because the browser starts from current computed style, interrupting an animation mid-flight automatically transitions smoothly from wherever the element is — just provide a new end value and re-trigger with animate.

📖 See Interrupting Animations for more info.

OnLoad Animations

If a transition must run immediately on page load, defer triggering to the next event loop tick with Process.sleep 0. This lets the initial render commit first so the browser has a starting computed style to transition from. Without it, the browser sees only the end value on first paint and moves immediately to it, with no animation.

View Source Code
init : ( Model, Cmd Msg )
init =
    ( { animState = Transition.init [ Opacity.init "card" 0 ] }
    , Process.sleep 0 |> Task.perform (\_ -> TriggerFadeIn)
    )

Update

Use update to process incoming transition messages. It returns the updated AnimState and a Maybe AnimEvent.

View Source Code
GotAnimMsg animMsg ->
    let
        ( animState, maybeEvent ) =
            Transition.update animMsg model.animState
    in
    handleAnimEvent maybeEvent { model | animState = animState }

Events

update returns a single Maybe AnimEvent per call. Each event carries three values:

  • the id (if one exists) of the element that fired the event (CurrentTargetId),
  • the id (if one exists) of the element that owns the listener (TargetId), and,
  • the animation group name.

In most cases only the group name is needed. CurrentTargetId and TargetId may or may not be the same depending on whether the event has bubbled up.

View Source Code
handleAnimEvent : Maybe Transition.AnimEvent -> Model -> ( Model, Cmd Msg )
handleAnimEvent maybeEvent model =
    case maybeEvent of
        Just (Transition.Ended _ _ "card") ->
            ( model, Cmd.none )

        _ ->
            ( model, Cmd.none )
Event Fires when...
Run Transition is queued to run (before any delay)
Started Transition begins playing
Ended Transition completes
Cancelled Transition is interrupted by something outside the engine's control.

📖 See React for more info.

View

Apply attributes to the animated element to set its transition rules and inline styles.

View Source Code
div (Transition.attributes "card" model.animState) [ text "Card" ]

Event Listeners

Apply events alongside attributes to attach the DOM transition event listeners that drive update.

View Source Code
div
    (Transition.attributes "card" model.animState
        ++ Transition.events GotAnimMsg
    )
    [ text "Card" ]

Use eventsStopPropagation to prevent events from bubbling to parent elements.

📖 See Render for more info.

Responsive Strategy

Use relative CSS units whenever the motion can be defined in layout-relative terms and the Browser does the work.

For measured pixel targets, Transition has no proportional remap API for resize updates because mid-flight values are not available. Therefore:

  • On resize, recompute pixel targets and re-trigger with animate.
  • Running animations then continue smoothly from current computed style to the new target.
  • If a later layout change makes the old target wrong, use retarget to snap straight to the new correct position.

📖 See Responsive Animations for more info.

Timing

Set the default duration, speed, and delay. Inherited by every property that doesn't override them.

  • duration — animation length in milliseconds.
  • speed — alternative to duration; set a rate in property units per second.
  • delay — wait before the transition begins, in milliseconds.
View Source Code
fadeIn =
    Transition.delay 500
        >> Transition.duration 800
        >> Transition.for "card"
        >> Opacity.begin
        >> Opacity.to 1
        >> Opacity.end

📖 See Timing for more info.

Easing

Easings are converted to CSS cubic-bezier values for the browser to render natively.

Most standard easings (sine, quad, cubic, quart, quint, expo) convert accurately. However, complex curves like bounce and elastic are approximated and won't match their mathematical definitions exactly.

For accurate complex easing curves, use the Keyframe, Sub, or WAAPI engine instead.

Set the default easing for all properties that don't override it:

View Source Code
fadeIn =
    Transition.easing CubicInOut
        >> Transition.for "card"
        >> Opacity.begin
        >> Opacity.to 1
        >> Opacity.duration 300
        >> Opacity.delay 50
        >> Opacity.end

📖 See Easing for all available easing functions.

Controls

stop jumps the animation to its end state. reset jumps to the start state.

View Source Code
Stop ->
    ( { model | animState = Transition.stop "card" model.animState }, Cmd.none )

Reset ->
    ( { model | animState = Transition.reset "card" model.animState }, Cmd.none )

📖 See Controlling Animations for more info.

Discrete Properties

The Transition engine uses discreteEntry and discreteExit — the same API as all other engines.

For this engine, calling either function enables the browser's native transition-behavior: allow-discrete CSS feature.

For entry animations, include startingStyleNode in your view. This generates @starting-style CSS rules so the browser knows the interpolable property values to animate from when an element first appears. Without it, entry transitions are skipped.

View Source Code
fadeIn : AnimBuilder eng -> AnimBuilder eng
fadeIn =
    Opacity.begin
        >> Opacity.to 1
        >> Opacity.end

fadeOut : AnimBuilder eng -> AnimBuilder eng
fadeOut =
    Opacity.begin
        >> Opacity.to 0
        >> Opacity.end

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        FadeBoxIn ->
            ({ model | animState = 
                Transition.animate model.animState <|
                    Transition.for "box"
                        >> Transition.discreteEntry "display" "block"
                        >> fadeIn                    
            }
            , Cmd.none
            )

        FadeBoxOut ->
            ({ model | animState = 
                Transition.animate model.animState <|
                    Transition.for "box"
                        >> Transition.discreteExit "display" "block" "none"
                        >> fadeOut                    
            }
            , Cmd.none
            )

view : Model -> Html Msg
view model =
    div []
        [ Transition.startingStyleNode model.animState
        , div
            (Transition.attributes "box" model.animState
                ++ Transition.events GotAnimMsg
                ++ [ style "display" "none" ]
            )
            [ text "Hello!" ]
        ]

Browser Support

transition-behavior: allow-discrete requires modern browsers (Chrome 117+, Firefox 129+, Safari 18+). In older browsers, discrete property transitions won't animate — the property will change instantly.

📖 See Discrete Properties for the full API, live examples, and source code.

State Queries

Query animation state.

View Source Code
Transition.anyRunning model.animState            -- Maybe Bool
Transition.allComplete model.animState           -- Maybe Bool
Transition.isRunning "card" model.animState      -- Maybe Bool
Transition.isComplete "card" model.animState     -- Maybe Bool
Transition.isCancelled "card" model.animState    -- Maybe Bool

Nothing is returned when no animation exists.

Property Queries

No start or current values are queryable for CSS Transitions, so all that is available are the end values provided in the animation builders.

All query functions follow the same pattern: get[Property]End, and return Maybe [PropertyValue].

View Source Code
Transition.getOpacityEnd "card" model.animState     -- Maybe Float
Transition.getTranslateEnd "card" model.animState   -- Maybe { x, y, z }
...

Nothing is returned when no animation exists for the given group.

When to Choose This Engine

Choose Transition when you want minimal setup and smooth A→B animations.

  • Best for: UI interactions, hovers, toggles, and small component transitions.
  • Avoid when: you need richer playback control or mid-flight visibility.

API Quick Reference

Types

Type Description
AnimState Tracks animations and their states
AnimBuilder eng Carries all animation configurations
AnimMsg Internal engine messages
AnimEvent Events received during a transition's lifecycle
AnimGroupName String type alias for the animation group name
CurrentTargetId String type alias for the element that fired the event
TargetId String type alias for the element that owns the listener

Initialize

Function Type Description
init List (AnimBuilder eng -> AnimBuilder eng) -> AnimState Create initial animation state

Trigger

Function Type Description
animate AnimState -> (AnimBuilder eng -> AnimBuilder eng) -> AnimState Apply an animation to the current state

Events

Event Description
Run CurrentTargetId TargetId AnimGroupName Transition is queued to run
Started CurrentTargetId TargetId AnimGroupName Transition begins playing
Ended CurrentTargetId TargetId AnimGroupName Transition completes
Cancelled CurrentTargetId TargetId AnimGroupName Transition is cancelled

Update

Function Type Description
update AnimMsg -> AnimState -> (AnimState, Maybe AnimEvent) Process transition messages

View

Function Type Description
attributes AnimGroupName -> AnimState -> List (Html.Attribute msg) Get transition attributes for an element

Event Listeners

Function Type Description
events (AnimMsg -> msg) -> List (Html.Attribute msg) Attach all transition event listeners
eventsStopPropagation (AnimMsg -> msg) -> List (Html.Attribute msg) Attach all listeners, stops propagation

Timing

Function Type Description
duration Int -> AnimBuilder eng -> AnimBuilder eng Set duration (ms)
speed Float -> AnimBuilder eng -> AnimBuilder eng Set speed (property units/sec)
delay Int -> AnimBuilder eng -> AnimBuilder eng Set delay before transition starts (ms)

Easing

Function Type Description
easing Easing -> AnimBuilder eng -> AnimBuilder eng Set easing function

Controls

Function Type Description
stop AnimGroupName -> AnimState -> AnimState Jump to end state and stop
reset AnimGroupName -> AnimState -> AnimState Jump to start state and stop

Discrete Properties

Function Type Description
discreteEntry String -> String -> AnimBuilder eng -> AnimBuilder eng Set a discrete CSS property value for entry animations
discreteExit String -> String -> String -> AnimBuilder eng -> AnimBuilder eng Set a discrete CSS property value for exit animations
startingStyleNode AnimState -> Html msg Generate @starting-style rules for all groups
startingStyleNodeFor AnimGroupName -> AnimState -> Html msg Generate @starting-style rules for a specific group

State Queries

Function Type Description
anyRunning AnimState -> Maybe Bool Check if any animation is running
isRunning AnimGroupName -> AnimState -> Maybe Bool Check if a specific group is animating
allComplete AnimState -> Maybe Bool Check if all animations are complete
isComplete AnimGroupName -> AnimState -> Maybe Bool Check if a specific group's animation is complete
isCancelled AnimGroupName -> AnimState -> Maybe Bool Check if a specific group's animation was cancelled

Property Queries

CSS transitions track only end values.

Function Type Description
getOpacityEnd AnimGroupName -> AnimState -> Maybe Float Get end opacity
getTranslateEnd AnimGroupName -> AnimState -> Maybe { x, y, z } Get end translate
getRotateEnd AnimGroupName -> AnimState -> Maybe { x, y, z } Get end rotate
getScaleEnd AnimGroupName -> AnimState -> Maybe { x, y, z } Get end scale
getSizeEnd AnimGroupName -> AnimState -> Maybe { width, height } Get end size
getSkewEnd AnimGroupName -> AnimState -> Maybe { x, y } Get end skew
getPropertyEnd AnimGroupName -> String -> AnimState -> Maybe Float Get end value for a custom numeric property
getColorPropertyEnd AnimGroupName -> String -> AnimState -> Maybe Color Get end value for a custom color property

For complete API details, see the Anim.Engine.Transition documentation.

Next Steps

The Keyframe Engine which provides a few different features to what you get with transitions.

Keyframe Engine →