Skip to main content
All Examples

View Transitions

Animated route changes with the View Transitions API. Direction-aware slides via transition types and a shared-element morph from gallery card to detail hero.

Routing
Animation
/
import { Array, Effect, Match as M, Option, Schema as S, String } from 'effect'
import { Command, Runtime } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { m } 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 { ArtworkRoute, GalleryRoute, NotFoundRoute } from './route'

// MODEL

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

// MESSAGE

export const CompletedNavigateInternal = m('CompletedNavigateInternal')
export const CompletedLoadExternal = m('CompletedLoadExternal')
export const ClickedLink = m('ClickedLink', { request: UrlRequest })
export const ChangedUrl = m('ChangedUrl', { url: Url })
export const UpdatedFilterText = m('UpdatedFilterText', {
  filterText: S.String,
})

export const Message = S.Union([
  CompletedNavigateInternal,
  CompletedLoadExternal,
  ClickedLink,
  ChangedUrl,
  UpdatedFilterText,
])
export type Message = typeof Message.Type

// INIT

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

// COMMAND

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

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

// UPDATE

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const withUpdateReturn = M.withReturnType<UpdateReturn>()

export const update = (model: Model, message: Message): UpdateReturn =>
  M.value(message).pipe(
    withUpdateReturn,
    M.tagsExhaustive({
      CompletedNavigateInternal: () => [model, []],
      CompletedLoadExternal: () => [model, []],

      ClickedLink: ({ request }) =>
        M.value(request).pipe(
          withUpdateReturn,
          M.tagsExhaustive({
            Internal: ({ url }) => [
              model,
              [NavigateInternal({ url: urlToString(url) })],
            ],
            External: ({ href }) => [model, [LoadExternal({ href })]],
          }),
        ),

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

      UpdatedFilterText: ({ filterText }) => [
        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: M.type<AppRoute>().pipe(
      M.withReturnType<Runtime.ViewTransitionDecision>(),
      M.tagsExhaustive({
        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 => 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 =>
  M.value(route).pipe(
    M.tagsExhaustive({
      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 = M.value(model.route).pipe(
    M.tagsExhaustive({
      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]),
      ],
    ),
  }
}