Skip to example

AnimateView: App Store

The App Store card-to-detail shared element transition, recreated with named AnimateView components instead of layoutId.

Vue

Source code

<script setup lang="ts">
import { computed, ref } from 'vue'
import { AnimateView, spring, startTransition } from 'motion-v'

interface CardItem {
  id: string
  category: string
  title: string
  content: string
  theme?: 'light' | 'dark'
  top?: number
  bottom?: number
  width?: string
  left?: number
}

const openId = ref<string | null>(null)

function open(id: string) {
  startTransition(() => {
    openId.value = id
  })
}

function close() {
  startTransition(() => {
    openId.value = null
  })
}

const openItem = computed(() => items.find(item => item.id === openId.value))

const cardTransition = {
  type: spring,
  visualDuration: 0.3,
  bounce: 0.2,
}

const openCardTransition = {
  type: spring,
  visualDuration: 0.4,
  bounce: 0.3,
}

function imageStyle(item: CardItem) {
  return {
    top: item.top !== undefined ? `${item.top}px` : 'auto',
    bottom: item.bottom !== undefined ? `${item.bottom}px` : 'auto',
    width: item.width || '100%',
    left: item.left !== undefined ? `${item.left}px` : 'auto',
  }
}

// Photos by Unsplash
const items: CardItem[] = [
  {
    id: 'a',
    category: 'Travel',
    title: '5 Inspiring Apps for Your Next Trip',
    content: `
      <p class="big">
        Love to travel? So do the makers of these five subscription
        apps. For a small monthly fee, they'll help you find the
        best deals on flights, hotels, and some other stuff we turn
        a blind eye to.
      </p>
      <p class="big">
        Plan your perfect itinerary with intelligent recommendations
        based on your interests, time, and credit history.
      </p>
    `,
    top: -300,
    theme: 'light',
  },
  {
    id: 'c',
    category: 'How to',
    title: 'Contemplate the Meaning of Life Twice a Day',
    content: `
      <p class="big">
        What is life? You can't spell "life" without "i". You also
        can't spell "life" without "l", "f", and "e". Worth thinking
        about.
      </p>
      <p class="big">
        The only way to find out more about life is to think about
        it. And the only way to think about it is twice daily using
        an app.
      </p>
      <p class="big">
        Apps? We got 'em. Therefore we got the meaning of life.
      </p>
    `,
    bottom: -50,
    theme: 'dark',
    width: '110%',
    left: -20,
  },
  {
    id: 'd',
    category: 'Steps',
    title: 'Urban Exploration Apps for the Vertically-Inclined',
    content: `
      <p class="big">
        Get off the beaten path. Find the best views, skywalks, and
        elevated gardens in your city.
      </p>
      <p class="big">
        Locked door? No problem! This app crowdsources the access
        code to every door in your city.
      </p>
    `,
    theme: 'dark',
    width: '200%',
    left: -100,
  },
  {
    id: 'b',
    category: 'Hats',
    title: 'Take Control of Your Hat Life With This Stunning New App',
    content: `
      <p class="big">
        Whether you're serious hat enthusiast, or just a filthy
        casual, this new app revolutionizes how you organize, care
        for, and expand your hat collection.
      </p>
      <p class="big">
        Stay up to date with the latest hat trends, get personalized
        hat care reminders, and use predictive analytics to discover
        the last place you left your hat.
      </p>
      <p class="big">
        Why follow the crowd when you can be the crowd?
      </p>
    `,
    bottom: -100,
    theme: 'light',
  },
]
</script>

<template>
  <AnimateView>
    <div class="app-store">
      <template v-if="!openItem">
        <header>
          <div>
            <h2 class="store-title">
              Today
            </h2>
          </div>
          <div class="avatar">
            <img
              src="https://examples.motion.dev/authors/matt-perry.png"
              alt="Photo of Matt Perry"
              width="40"
              height="40"
            >
          </div>
        </header>
        <ul class="card-list">
          <li
            v-for="item in items"
            :key="item.id"
            :class="`card ${item.theme || ''}`"
          >
            <AnimateView
              :name="`card-${item.id}`"
              :transition="cardTransition"
            >
              <div
                class="card-content"
                @click="open(item.id)"
              >
                <div class="card-image-container">
                  <img
                    class="card-image"
                    :src="`https://examples.motion.dev/photos/app-store/${item.id}.jpg`"
                    alt=""
                    :style="imageStyle(item)"
                  >
                </div>
              </div>
            </AnimateView>
          </li>
        </ul>
      </template>
      <template v-else>
        <div
          class="overlay"
          @click="close"
        />
        <div :class="`card-content-container open ${openItem.theme || ''}`">
          <AnimateView
            :name="`card-${openItem.id}`"
            :transition="openCardTransition"
          >
            <div class="card-content">
              <div class="card-image-container">
                <img
                  class="card-image"
                  :src="`https://examples.motion.dev/photos/app-store/${openItem.id}.jpg`"
                  alt=""
                  :style="imageStyle(openItem)"
                >
              </div>
              <div class="title-container">
                <span class="h6">{{ openItem.category }}</span>
                <h2 class="h3">
                  {{ openItem.title }}
                </h2>
              </div>
              <div class="content-container small">
                <div v-html="openItem.content" />
              </div>
            </div>
          </AnimateView>
        </div>
      </template>
    </div>
  </AnimateView>
</template>

<style>
/* Global rules: view-transition pseudo-elements and sandbox chrome
   live outside this component, so they can't be scoped */

::view-transition-image-pair(*) {
  mix-blend-mode: normal;
}

/* Same as the original React example: keep top alignment after collapse,
   so the outer boundary's morph doesn't introduce drift */
#sandbox {
  justify-content: flex-start;
}

body {
  scrollbar-gutter: stable;
}
</style>

<style scoped>
.app-store {
  margin: 0 auto;
  width: 100%;
  max-width: 990px;
  display: flex;
  flex-direction: column;
  padding-top: 150px;
  padding-bottom: 150px;
}

header {
  position: relative;
  margin-bottom: 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.avatar {
  background: var(--layer);
  border-radius: 50%;
  border: 1px solid var(--divider);
  width: 40px;
  height: 40px;
  overflow: hidden;
}

.store-title {
  font-variation-settings: "opsz" 20, "wght" 640;
  font-size: 34px;
  margin: 5px 0;
  letter-spacing: -0.05em;
  color: var(--text);
}

ul,
li {
  list-style: none;
  padding: 0;
  margin: 0;
}

.card-list {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 20px;
}

.card {
  position: relative;
  padding: 0px;
  height: 420px;
  flex: 0 0 40%;
  box-sizing: border-box;
}

.card:nth-child(4n + 1),
.card:nth-child(4n + 4) {
  flex: 0 1 calc(60% - 20px);
}

.card-content-container {
  width: 100%;
  height: 100%;
  position: relative;
  display: flex;
  pointer-events: none;
  flex-direction: column;
  justify-content: flex-end;
}

.card-content-container.open {
  top: 0;
  left: 0;
  right: 0;
  position: fixed;
  z-index: 1000001;
  overflow: hidden;
  padding: 40px 0;
  justify-content: center;
}

.dark .h3,
.dark span {
  color: var(--black);
}

.card-content {
  pointer-events: auto;
  position: relative;
  border-radius: 20px;
  background: var(--layer);
  overflow: hidden;
  width: 100%;
  height: 100%;
  margin: 0 auto;
  cursor: pointer;
}

.open .card-content {
  width: unset;
  height: unset;
  max-width: 700px;
  overflow: hidden;
  pointer-events: none;
  cursor: default;
}

.card-image-container {
  overflow: hidden;
  height: 420px;
  display: flex;
  justify-content: stretch;
  flex-direction: column;
  position: relative;
}

.card-image {
  width: 100%;
  position: absolute;
}

.open .card-image-container,
.open .title-container {
  z-index: 1;
}

.title-container {
  position: absolute;
  top: 15px;
  left: 15px;
  max-width: 300px;
}

.open .title-container {
  top: 30px;
  left: 30px;
}

.title-container h2 {
  color: #fff;
  margin: 8px 0;
  text-wrap: balance;
}

.h6 {
  color: #fff;
  font-size: 14px;
  text-transform: uppercase;
}

.overlay {
  inset: 0;
  z-index: 1000000;
  position: fixed;
  background: rgba(0, 0, 0, 0.8);
  will-change: opacity;
}

.content-container {
  padding: 35px;
  max-width: 700px;
  width: 90vw;
}

/* v-html content doesn't carry the scoped attribute */
:deep(p.big) {
  margin-bottom: 10px;
}

@media only screen and (max-width: 990px) {
  .app-store {
    padding: 60px 20px;
    padding-left: 10px;
    padding-right: 10px;
  }

  .card-list {
    gap: 10px;
  }

  .card {
    flex: 0 1 calc(50% - 5px);
    height: 280px;
  }

  li .card-image {
    top: unset !important;
    bottom: -10px !important;
    left: 0 !important;
    right: 0 !important;
    min-width: 250px;
    min-height: 300px;
  }

  li .title-container {
    visibility: hidden;
  }

  .card-image-container {
    height: 280px;
  }

  .card:nth-child(4n + 1),
  .card:nth-child(4n + 4) {
    flex: 0 1 calc(50% - 5px);
    max-width: 100%;
  }

  .card-content-container.open {
    padding: 0;
  }
}
</style>