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;
}
}`;