Animation

Styled enter/leave animation coordinator, built on the @foldkit/ui Animation submodel.

Preview
Style
src/demo/views/animation.ts106 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Animation as FoldkitAnimation } from '@foldkit/ui'

import * as animation from '../../generated/registry/ui/animation'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotAnimationMessage: { message: animation.Message },
  ToggledAnimation: {},
})

export const animationView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex flex-col items-center gap-4')],
    [
      h.button(
        [
          h.Class('rounded-md border border-input bg-background px-4 py-2 text-sm font-medium'),
          h.OnClick(Message.ToggledAnimation()),
        ],
        [model.isAnimationShowing ? 'Hide content' : 'Show content'],
      ),
      h.submodel({
        slotId: model.animation.id,
        model: model.animation,
        view: animation.view,
        viewInputs: animation.styledViewInputs({
          animateSize: true,
          content: h.div(
            [h.Class('flex flex-col gap-2')],
            [
              h.p([h.Class('text-foreground')], ['This card animates in and out.']),
              h.p(
                [h.Class('text-sm text-muted-foreground')],
                [
                  'The Animation component coordinates CSS enter/leave lifecycles via data attributes; animateSize uses a CSS grid wrapper for smooth height animation.',
                ],
              ),
            ],
          ),
        }),
        toParentMessage: (message) => Message.GotAnimationMessage({ message }),
      }),
    ],
  )

const foldAnimationOutMessage: (
  outMessage: animation.OutMessage,
  context: Update.FoldContext<animation.Message, unknown>,
) => Update.Step<State, unknown> = (outMessage, { liftCommand }) =>
  M.value(outMessage).pipe(
    M.withReturnType<Update.Step<State, unknown>>(),
    M.tagsExhaustive({
      StartedLeaveAnimating: () => (model) => ({
        model,
        commands: [liftCommand(FoldkitAnimation.defaultLeaveCommand(model.animation))],
      }),
      TransitionedOut: () => (model) => ({ model }),
    }),
  )

const foldAnimation = Update.foldChild({
  update: animation.update,
  read: (model: State) => Option.some(model.animation),
  write: (model, next) => evo(model, { animation: () => next }),
  toParentMessage: (message) => Message.GotAnimationMessage({ message }),
  foldOutMessage: foldAnimationOutMessage,
})

const fields = {
  animation: animation.Model,
  isAnimationShowing: S.Boolean,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    animation: animation.init({ id: 'animation-demo' }),
    isAnimationShowing: false,
  },
  messages: [Message.GotAnimationMessage, Message.ToggledAnimation],
  handlers: (model: State) => ({
    GotAnimationMessage: (payload: typeof Message.GotAnimationMessage.Type): UpdateReturn =>
      foldAnimation(model, payload.message),
    ToggledAnimation: (): UpdateReturn => {
      const nextShowing = !model.isAnimationShowing
      return foldAnimation(
        evo(model, { isAnimationShowing: () => nextShowing }),
        nextShowing ? FoldkitAnimation.Message.Showed() : FoldkitAnimation.Message.Hid(),
      )
    },
  }),
  samples: [Message.ToggledAnimation()],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/animation

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/animation.ts60 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Animation from '@/components/ui/animation'`
 */
import { Animation as FoldkitAnimation } from '@foldkit/ui'
import type { ChildAttribute, Html, TagName } from 'foldkit/html'

import { cn } from '@/lib/utils'

// Re-export the @foldkit/ui Animation submodel surface.

export const init = FoldkitAnimation.init
export const update = FoldkitAnimation.update
export const view = FoldkitAnimation.view
export const Model = FoldkitAnimation.Model
export type Model = typeof Model.Type
export const Message = FoldkitAnimation.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitAnimation.OutMessage
export type OutMessage = typeof OutMessage.Type

export const TransitionState = FoldkitAnimation.TransitionState
export type TransitionState = typeof TransitionState.Type
export type Showed = FoldkitAnimation.Showed
export type Hid = FoldkitAnimation.Hid

export const WaitForPaint = FoldkitAnimation.WaitForPaint
export const WaitForAnimationSettled = FoldkitAnimation.WaitForAnimationSettled
export const defaultLeaveCommand = FoldkitAnimation.defaultLeaveCommand

export type InitConfig = FoldkitAnimation.InitConfig
export type ViewInputs = FoldkitAnimation.ViewInputs

// Mirrors the shadcn dialog enter/leave utilities (tw-animate-css):
// `animate-in`/`animate-out` keyed off the Foldkit Animation coordinator's
// `data-enter`/`data-leave` attributes (never `data-state`). `duration-200`
// matches the dialog panel and accordion 0.2s ease-out timing. The
// `fade-in-0`/`zoom-in-95` pair reproduces the reference dialog content
// animation (fade + subtle scale), decoupled from the `animateSize` grid
// height animation that the view handles via inline `grid-template-rows`.
export const animationContentClass =
  'rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm duration-200 data-[enter]:animate-in data-[enter]:fade-in-0 data-[enter]:zoom-in-95 data-[leave]:animate-out data-[leave]:fade-out-0 data-[leave]:zoom-out-95'

export type StyledViewInputs = Readonly<{
  content: Html
  className?: string
  animateSize?: boolean
  attributes?: ReadonlyArray<ChildAttribute>
  element?: TagName
}>

/** Build styled `Animation.ViewInputs` with foldcn's enter/leave classes. */
export const styledViewInputs = (viewInputs: StyledViewInputs): ViewInputs => ({
  content: viewInputs.content,
  className: cn(animationContentClass, viewInputs.className),
  animateSize: viewInputs.animateSize,
  attributes: viewInputs.attributes,
  element: viewInputs.element,
})