Motion should be adressed with css transitions whenever possible. The startViewTransition hook supports animating complex cases like animating between two completely different items. It's a wrapper around Document.startViewTransition().

To fully understand how the transitions work read about it here. To following examples will explain how to use view-transitions in our stack.

import { startViewTransition } from "@blocks/motion";
import { Container, Dialog, Image, Thumbnail } from "../components.tsx";

export const DefaultViewTransition: FunctionComponent<{
  thumbnails: ThumbnailData[];
}> = ({ thumbnails }) => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);

  return (
    <>
      <Container>
        {thumbnails.map((thumbnail) => (
          <Thumbnail
            key={thumbnail.name}
            onClick={() => startViewTransition(() => setIsDialogOpen(true))}
          />
        ))}
      </Container>
      {isDialogOpen && (
        <Dialog>
          <Image />
        </Dialog>
      )}
    </>
  );
};

By default the transition will always cross-fade between the two states. That animation can be targeted through the ::view-transition-group(root) selector. Create a css file to target the selector and change the speed of the animation.

/** slowViewTransitionGlobalStyle.css */
::view-transition-group(root) {
  /** slow down the default transition */
  animation-duration: 1000ms;
}

Import the css file in your component file to apply those styles globally.

import "./slowViewTransitionGlobalStyle.css";

export const SlowViewTransition: FunctionComponent = () => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  import { Thumbnail } from "../components.tsx";

  return (
    <>
      <Thumbnail
        onClick={() => startViewTransition(() => setIsDialogOpen(false))}
      />
      ...
    </>
  );
};
Show codeHide code
export const DefaultViewTransitionDemo: FunctionComponent<
  IDefaultViewTransitionDemoProps
> = ({ contentWindow }) => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);

  const thumbnails: ReactNode[] = [];
  for (let index = 0; index < 3; index++) {
    thumbnails.push(
      <ThumbnailContainer
        key={index}
        onClick={() => {
          startViewTransition(() => setIsDialogOpen(true), contentWindow);
        }}
      >
        <Thumbnail />
      </ThumbnailContainer>,
    );
  }

  return (
    <>
      {/** These styles are only added this way for demo purposes. */}
      <style>
        {`::view-transition-group(root) {
        animation-duration: ${duration[1000]};
        }`}
      </style>
      <Container>{thumbnails}</Container>
      {isDialogOpen && (
        <Dialog
          onClick={() =>
            startViewTransition(() => setIsDialogOpen(false), contentWindow)
          }
        >
          <StyledImageContainer>
            <Image />
          </StyledImageContainer>
        </Dialog>
      )}
    </>
  );
};

#Targeting multiple elements

Instead of animating the entire page, individual parts of the page can be animated. This can be done by adding a view-transition-name to an element. The name needs to be unique and only be set on one element at a time. The set view-transition-name can now also be targeted by ::view-transition-group(name)

Show codeHide code
export const ReorderViewTransitionDemo: FunctionComponent<
  IReorderViewTransitionDemoProps
> = ({ contentWindow }) => {
  const [selectedIndex, setSelectedIndex] = useState(-1);

  const elements: ReactNode[] = [];
  for (let index = 0; index < 3; index++) {
    elements.push(
      <StyledThumbnailContainer
        key={index}
        onClick={() =>
          startViewTransition(() => setSelectedIndex(index), contentWindow)
        }
        $isSelected={selectedIndex === index}
        style={{ viewTransitionName: `reorder-thumbnail-${index}` }}
      >
        <StyledThumbnail $isSelected={selectedIndex === index}>
          {index + 1}
        </StyledThumbnail>
      </StyledThumbnailContainer>,
    );
  }
  return (
    <>
      {/** These styles are only added this way for demo purposes. */}
      <style>
        {`::view-transition-group(.${viewTransitionClass}) {
        animation-duration: ${duration[500]};
        animation-timing-function: ${easing.standard};
        }`}
      </style>
      <Container>{elements}</Container>
    </>
  );
};

import "reorderGlobalStyles.css";
import { startViewTransition } from "@blocks/motion";
import { styled } from "next-yak";
import { Container, Thumbnail } from "../components.tsx";

export const ReorderViewTransition: FunctionComponent<{
  thumbnails: ThumbnailData[];
}> = ({ thumbnails }) => {
  const [selectedIndex, setSelectedIndex] = useState(-1);

  return (
    <Container>
      {thumbnails.map((thumbnail) => (
        <Thumbnail
          key={index}
          onClick={() => startViewTransition(() => setSelectedIndex(index))}
          $isSelected={selectedIndex === index}
          /** Add an unqique view-transition-name to every thumbnail in the list */
          style={{ viewTransitionName: `reorder-thumbnail-${index}` }}
        />
      ))}
    </Container>
  );
};

const viewTransitionClass = "thumbnail-container";

const Thumbnail = styled.div`
  /**
  * The view transition class will be set on every thumbnail
  * and lets you target multiple elements
  * with different view-transition-names
  */
  view-transition-class: ${viewTransitionClass};
`;

Since the view-transition-class is set on every thumbnail it can be used to target every thumbnail and change the animation through a separate css file.

/**
  * The view transition class will be set on every thumbnail
  * and lets you target multiple elements
  * with different view-transition-names
  */
::view-transition-group(.thumbnail-container) {
  animation-duration: 500ms;
  /** standard easing */
  animation-timing-function: cubic-bezier(0.83, 0, 0.17, 1);
}

#Transition between different DOM elements

By elements sharing the same view-transition-name it's possible to transition between them. Like a thumbnail of an image and the gallery view of that image. It's important to only set the view-transition-name on one element at a a time.

Show codeHide code
export const StartViewTransitionGalleryDemo: FunctionComponent<
  IStartViewtransitionGalleryDemoProps
> = ({ contentWindow }) => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);

  const thumbnails: ReactNode[] = [];
  for (let index = 0; index < 3; index++) {
    thumbnails.push(
      <StyledThumbnailContainer
        key={index}
        onClick={() => {
          setSelectedIndex(index);
          startViewTransition(() => setIsDialogOpen(true), contentWindow);
        }}
        $isHidden={isDialogOpen && index === selectedIndex}
        $hasTransitionName={!isDialogOpen && index === selectedIndex}
      >
        <Thumbnail />
      </StyledThumbnailContainer>,
    );
  }

  return (
    <>
      {/** These styles are only added this way for demo purposes. */}
      <style>
        {`html::view-transition-group(${imageTransitionName}) {
        animation-duration: ${duration[500]};
        animation-timing-function: ${easing.standard};
        opacity: 1;
        }`}
      </style>
      <Container>{thumbnails}</Container>
      {isDialogOpen && (
        <Dialog
          onClick={() =>
            startViewTransition(() => setIsDialogOpen(false), contentWindow)
          }
        >
          <StyledImageContainer $hasTransitionName={isDialogOpen}>
            <Image />
          </StyledImageContainer>
        </Dialog>
      )}
    </>
  );
};

/** imageGalleryGlobalStyle.css */
html::view-transition-group(thumbnail-image) {
  animation-duration: 500ms;
  /** standard easing */
  animation-timing-function: cubic-bezier(0.83, 0, 0.17, 1);
  opacity: 1;
}
import "./imageGalleryGlobalStyle.css";
import { startViewTransition } from "@blocks/motion";
import { Container, Dialog, Image, Thumbnail } from "../components.tsx";

export const ImageGallery: FunctionComponent<{
  thumbnails: ThumbnailData[];
}> = ({ thumbnails }) => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);

  return (
    <>
      <Container>
        {thumbnails.map((thumbnail, index) => (
          <Thumbnail
            key={index}
            onClick={() => {
              setSelectedIndex(index);
              startViewTransition(() => setIsDialogOpen(true));
            }}
            /**
             * Only set the transition name when the dialog is closed
             * and this specific index has been selected
             */
            $hasTransitionName={!isDialogOpen && index === selectedIndex}
          />
        ))}
      </Container>
      {isDialogOpen && (
        <Dialog
          /** Only set the transition name when the dialog is open */
          onClick={() => startViewTransition(() => setIsDialogOpen(false))}
        >
          <Image $hasTransitionName={isDialogOpen} />
        </Dialog>
      )}
    </>
  );
};

const imageTransitionName = "thumbnail-image";

const Thumbnail = styled.div<{
  $hasTransitionName?: boolean;
}>`
  /** Set the view-transition-name conditionally */
  view-transition-name: ${({ $hasTransitionName }) =>
    $hasTransitionName && imageTransitionName};
`;

const Image = styled.div<{ $hasTransitionName?: boolean }>`
  view-transition-name: ${({ $hasTransitionName }) =>
    $hasTransitionName && imageTransitionName};
`;

#Reduced motion

When reduced motion is active a simple crossfade is preferred which is the default animation on the root. Therefore the view-transition-name should only be set when the reduced-motion is not set:

import { noMotionPreference } from "@blocks/theme";

const ImageThumbnail = styled.div`
  /** only animate the thumbnail when prefers reduced motion is NOT set */
  ${noMotionPreference} {
    view-transition-name: gallery-image;
  }
`;

Alternatively the animation can also be disabled on the transition-group itself:

html::view-transition-group(gallery-image) {
  animation-duration: 400ms;

  /** completely deactivate an animation  */
  @media (prefers-reduced-motion: reduce) {
    animation: none;
  }
}`;