<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>