Skip to main content
All Examples

View Transitions

Animated route changes with the View Transitions API. Transition types control direction-aware slides, and a shared element morphs from gallery card to detail hero.

Routing
Animation
/
import { Array, Effect, Option, Schema, String } from 'effect'
import { Command, Runtime, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
import { Transition } from 'foldkit/route'
import { evo } from 'foldkit/struct'
import { Url, toString as urlToString } from 'foldkit/url'

import { Artwork, artworks, findArtwork } from './artwork'
import { AppRoute, artworkRouter, galleryRouter, urlToAppRoute } from './route'

export { AppRoute } from './route'

// MODEL

export const Model = Schema.Struct({
  route: AppRoute,
  filterText: Schema.String,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  CompletedNavigateInternal: {},
  CompletedLoadExternal: {},
  ClickedLink: { request: UrlRequest },
  ChangedUrl: { url: Url },
  UpdatedFilterText: { filterText: Schema.String },
})

export type Message = typeof Message.Type

// INIT

export const init: Runtime.RoutingApplicationInit<Model, Message> = (
  url: Url,
) => ({ model: { route: urlToAppRoute(url), filterText: '' } })

// COMMAND

const NavigateInternal = Command.define('NavigateInternal', {
  args: { url: Schema.String },
  messages: [Message.CompletedNavigateInternal],
  execute: ({ url }) =>
    pushUrl(url).pipe(Effect.as(Message.CompletedNavigateInternal())),
})

const LoadExternal = Command.define('LoadExternal', {
  args: { href: Schema.String },
  messages: [Message.CompletedLoadExternal],
  execute: ({ href }) =>
    load(href).pipe(Effect.as(Message.CompletedLoadExternal())),
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    CompletedNavigateInternal: () => ({ model }),
    CompletedLoadExternal: () => ({ model }),

    ClickedLink: ({ request }) =>
      UrlRequest.match<UpdateReturn>(request, {
        Internal: ({ url }) => ({
          model,
          commands: [NavigateInternal({ url: urlToString(url) })],
        }),
        External: ({ href }) => ({
          model,
          commands: [LoadExternal({ href })],
        }),
      }),

    ChangedUrl: ({ url }) => ({
      model: evo(model, { route: () => urlToAppRoute(url) }),
    }),

    UpdatedFilterText: ({ filterText }) => ({
      model: evo(model, { filterText: () => filterText }),
    }),
  })

// NOTE: every arm past the guard is a navigation, so none return `false`.
// `true` is "animate, with no direction to declare": the root fade in
// `styles.css` is not type-scoped, so an untyped transition still cross-fades,
// it just does not slide.
export const viewTransition: Runtime.ViewTransitionConfig<Model, Message> = ({
  previousModel,
  model,
  message,
}) => {
  if (message._tag !== 'ChangedUrl') {
    return false
  }

  const transition = Transition.make(previousModel.route, model.route)

  return Option.match(Transition.enteredAny(transition), {
    onNone: () => true,
    onSome: AppRoute.match<Runtime.ViewTransitionDecision>({
      Artwork: () => ({ types: ['to-artwork-detail'] }),
      Gallery: () => ({ types: ['to-gallery'] }),
      NotFound: () => true,
    }),
  })
}

// VIEW

const transitionNameFor = (artworkId: number): string => `artwork-${artworkId}`

// NOTE: names have to be unique per snapshot, so each artwork carries its own.
// The shared class is what lets `styles.css` reach all of them with one rule,
// since a per-artwork name cannot be written as a selector ahead of time.
const artworkTransitionStyle = (artworkId: number) => ({
  viewTransitionName: transitionNameFor(artworkId),
  viewTransitionClass: 'artwork',
})

const filteredArtworks = (filterText: string): ReadonlyArray<Artwork> => {
  const normalizedFilter = filterText.trim().toLowerCase()

  if (String.isEmpty(normalizedFilter)) {
    return artworks
  } else {
    return Array.filter(artworks, artwork =>
      String.includes(normalizedFilter)(
        `${artwork.title} ${artwork.medium}`.toLowerCase(),
      ),
    )
  }
}

const artworkCardView = (artwork: Artwork, h: HtmlBuilder<Message>): Html =>
  h.keyed('a')(
    `artwork-${artwork.id}`,
    [h.Href(artworkRouter({ artworkId: artwork.id })), h.Class('group block')],
    [
      h.div([
        h.Class(
          `aspect-square rounded-xl bg-gradient-to-br shadow-sm transition-shadow group-hover:shadow-lg ${artwork.gradientClassName}`,
        ),
        h.Style(artworkTransitionStyle(artwork.id)),
      ]),
      h.p([h.Class('mt-3 font-medium text-gray-900')], [artwork.title]),
      h.p([h.Class('text-sm text-gray-500')], [artwork.medium]),
    ],
  )

const emptyGalleryView = (h: HtmlBuilder<Message>): Html =>
  h.p([h.Class('text-gray-500')], ['No artworks match the filter.'])

const artworkGridView = (
  matchedArtworks: ReadonlyArray<Artwork>,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class('grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4')],
    Array.map(matchedArtworks, artwork => artworkCardView(artwork, h)),
  )

const galleryView = (filterText: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('mx-auto max-w-5xl px-6')],
    [
      h.input([
        h.Type('search'),
        h.Value(filterText),
        h.Placeholder('Filter by title or medium…'),
        h.OnInput(value => Message.UpdatedFilterText({ filterText: value })),
        h.Class(
          'mb-8 w-full max-w-md rounded-lg border border-gray-300 bg-white px-4 py-2 focus:ring-2 focus:ring-gray-400 focus:outline-none',
        ),
      ]),
      Array.match(filteredArtworks(filterText), {
        onEmpty: () => emptyGalleryView(h),
        onNonEmpty: matchedArtworks => artworkGridView(matchedArtworks, h),
      }),
    ],
  )

const missingArtworkView = (artworkId: number, h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.h1(
        [h.Class('text-3xl font-bold text-gray-900')],
        ['Artwork not found'],
      ),
      h.p(
        [h.Class('mt-2 text-gray-600')],
        [`No artwork exists with ID ${artworkId}.`],
      ),
      h.a(
        [
          h.Href(galleryRouter()),
          h.Class('mt-4 inline-block text-gray-900 underline'),
        ],
        ['← Back to gallery'],
      ),
    ],
  )

// NOTE: the hero keeps the card's square ratio on purpose. A View Transition
// interpolates the old and new snapshots while it interpolates the box, so a
// square growing into a wider box visibly stretches mid-flight. Matching the
// ratio makes the morph a pure scale and translate, which is the one case the
// browser renders cleanly.
const foundArtworkView = (artwork: Artwork, h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.a(
        [
          h.Href(galleryRouter()),
          h.Class('text-gray-600 underline hover:text-gray-900'),
        ],
        ['← Back to gallery'],
      ),
      h.div(
        [
          h.Class(
            'mt-6 grid gap-10 md:grid-cols-[minmax(0,24rem)_1fr] md:items-center',
          ),
        ],
        [
          h.div([
            h.Class(
              `aspect-square w-full rounded-2xl bg-gradient-to-br shadow-md ${artwork.gradientClassName}`,
            ),
            h.Style(artworkTransitionStyle(artwork.id)),
          ]),
          h.div(
            [],
            [
              h.h1(
                [h.Class('text-3xl font-bold text-gray-900')],
                [artwork.title],
              ),
              h.p([h.Class('mt-1 text-sm text-gray-500')], [artwork.medium]),
              h.p(
                [h.Class('mt-4 text-lg leading-relaxed text-gray-700')],
                [artwork.description],
              ),
            ],
          ),
        ],
      ),
    ],
  )

const artworkDetailView = (artworkId: number, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('mx-auto max-w-5xl px-6')],
    [
      Option.match(findArtwork(artworkId), {
        onNone: () => missingArtworkView(artworkId, h),
        onSome: artwork => foundArtworkView(artwork, h),
      }),
    ],
  )

const notFoundView = (path: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('mx-auto max-w-3xl px-6')],
    [
      h.h1([h.Class('text-3xl font-bold text-gray-900')], ['Page not found']),
      h.p(
        [h.Class('mt-2 text-gray-600')],
        [`The path "${path}" does not exist.`],
      ),
      h.a(
        [
          h.Href(galleryRouter()),
          h.Class('mt-4 inline-block text-gray-900 underline'),
        ],
        ['← Back to gallery'],
      ),
    ],
  )

const routeTitle = (route: AppRoute): string =>
  AppRoute.match(route, {
    Gallery: () => 'View Transitions',
    Artwork: ({ artworkId }) =>
      Option.match(findArtwork(artworkId), {
        onNone: () => 'Artwork Not Found | View Transitions',
        onSome: artwork => `${artwork.title} | View Transitions`,
      }),
    NotFound: () => 'Page Not Found | View Transitions',
  })

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const routeContent = AppRoute.match(model.route, {
    Gallery: () => galleryView(model.filterText, h),
    Artwork: ({ artworkId }) => artworkDetailView(artworkId, h),
    NotFound: ({ path }) => notFoundView(path, h),
  })

  return {
    title: routeTitle(model.route),
    body: h.div(
      [h.Class('min-h-screen bg-gray-50 py-10')],
      [
        // NOTE: naming the header lifts it out of the root snapshot, so it
        // stays a live element the transition never touches. Without a name it
        // rides the page cross-fade and animates on every navigation despite
        // being identical on both routes, which reads as jank.
        h.header(
          [
            h.Class('mx-auto max-w-5xl px-6 pb-8'),
            h.Style({ viewTransitionName: 'page-header' }),
          ],
          [
            h.a(
              [
                h.Href(galleryRouter()),
                h.Class('text-2xl font-bold text-gray-900'),
              ],
              ['Gradient Gallery'],
            ),
            h.p(
              [h.Class('mt-1 text-gray-600')],
              [
                'Click an artwork and watch it grow into the detail page. Typing in the filter never animates.',
              ],
            ),
          ],
        ),
        h.main([], [routeContent]),
      ],
    ),
  }
}