<div class="dt-post">
<div class="dt-photo" aria-label="Double tap to like">
<img src="/photos/prague/image-02.jpg" alt="" draggable="false" />
</div>
<div class="dt-footer">
<button class="dt-like" aria-label="Like" aria-pressed="false">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 20.5s-7.5-4.6-9.3-9.2C1.4 8 3.4 4.5 6.9 4.5c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.5 3.5 4.2 6.8-1.8 4.6-9.3 9.2-9.3 9.2z"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="dt-count">
<span class="dt-number"><span>1,248</span></span>
<span class="dt-unit">likes</span>
</div>
<span class="dt-hint">Double tap the photo</span>
</div>
</div>
<script type="module">
import { animate, press } from "motion"
const photo = document.querySelector(".dt-photo")
const likeButton = document.querySelector(".dt-like")
const likePath = likeButton.querySelector("path")
const number = document.querySelector(".dt-number")
const ui = { type: "spring", stiffness: 305, damping: 33 }
const lively = { type: "spring", stiffness: 622, damping: 17 }
const red = "#ff3b5c"
const heartPath = likePath.getAttribute("d")
let isLiked = false
function setLiked(liked) {
if (liked === isLiked) return
isLiked = liked
likeButton.setAttribute("aria-pressed", liked)
likeButton.setAttribute("aria-label", liked ? "Unlike" : "Like")
animate(
likePath,
{
fill: liked ? red : "rgba(255, 59, 92, 0)",
stroke: liked ? red : "#18222e",
},
{ duration: 0.2 },
)
if (liked) animate(likeButton, { scale: [1, 1.35, 1] }, lively)
// Roll the count: the old number leaves, the new one follows
const direction = liked ? 1 : -1
const previous = number.firstElementChild
const next = document.createElement("span")
next.textContent = liked ? "1,249" : "1,248"
number.append(next)
animate(
previous,
{ y: -10 * direction, opacity: 0, filter: "blur(4px)" },
ui,
).then(() => previous.remove())
animate(
next,
{
y: [10 * direction, 0],
opacity: [0, 1],
filter: ["blur(4px)", "blur(0px)"],
},
ui,
)
}
function burst(x, y) {
const container = document.createElement("div")
container.className = "dt-burst"
container.style.left = x + "px"
container.style.top = y + "px"
container.innerHTML = `<svg class="dt-heart" width="96" height="96" viewBox="0 0 24 24" aria-hidden="true"><path d="${heartPath}" fill="#ffffff" /></svg>`
photo.append(container)
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2 - Math.PI / 2
const particle = document.createElement("span")
particle.className = "dt-particle"
container.append(particle)
animate(
particle,
{
x: Math.cos(angle) * 64,
y: Math.sin(angle) * 64,
scale: [1, 0],
opacity: [1, 1, 0],
},
{ duration: 0.6, ease: [0.2, 0, 0, 1], delay: 0.06 },
)
}
const heart = container.firstElementChild
// A bouncy spring overshoots, so the heart pops
animate(
heart,
{
scale: [0, 1],
opacity: [0, 1],
rotate: [0, Math.random() * 30 - 15],
},
{
type: "spring",
visualDuration: 0.3,
bounce: 0.6,
opacity: { duration: 0.1 },
},
)
// The exit runs on the container, so it doesn't stop the pop
animate(
container,
{ y: -48, scale: 0.7, opacity: 0 },
{ duration: 0.35, ease: "easeIn", delay: 0.5 },
).then(() => container.remove())
}
/**
* With count: 2, the gesture only succeeds after a second,
* quick press close to the first.
*/
press(
photo,
() =>
(endEvent, { success }) => {
if (!success) return
const rect = photo.getBoundingClientRect()
let x = endEvent.clientX - rect.left
let y = endEvent.clientY - rect.top
// Keyboard presses have no pointer position, so burst from the centre
if (x <= 0 || y <= 0 || x >= rect.width || y >= rect.height) {
x = rect.width / 2
y = rect.height / 2
}
burst(x, y)
setLiked(true)
},
{ count: 2 },
)
press(likeButton, () => {
animate(likeButton, { scale: 0.85 }, ui)
return (_event, { success }) => {
animate(likeButton, { scale: 1 }, ui)
if (success) setLiked(!isLiked)
}
})
</script>
<style>
.dt-post {
width: min(300px, calc(100vh - 140px));
padding: 8px;
border-radius: 24px;
background: #ffffff;
box-shadow:
0 0 0 1px rgba(24, 34, 46, 0.06),
0 1px 2px rgba(24, 34, 46, 0.06),
0 12px 32px rgba(24, 34, 46, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
-webkit-user-select: none;
}
.dt-photo {
position: relative;
aspect-ratio: 1;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.dt-photo:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.dt-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.dt-burst {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.dt-heart {
opacity: 0;
transform: scale(0);
position: absolute;
left: -48px;
top: -48px;
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.25));
}
.dt-particle {
position: absolute;
left: -4px;
top: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffffff;
}
.dt-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 8px 6px;
}
.dt-like {
width: 36px;
height: 36px;
margin: -4px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dt-like:focus-visible {
outline: 2px solid #2563eb;
}
.dt-count {
position: relative;
display: flex;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: #18222e;
font-variant-numeric: tabular-nums;
}
.dt-number {
position: relative;
display: inline-block;
min-width: 3.2em;
height: 1.2em;
overflow: hidden;
}
.dt-number span {
position: absolute;
left: 0;
top: 0;
}
.dt-unit {
font-weight: 400;
color: #60687a;
}
.dt-hint {
margin-left: auto;
font-size: 13px;
color: #9aa1ae;
white-space: nowrap;
}
@media (max-height: 420px) {
.dt-hint {
display: none;
}
}
.dt-like path {
fill: rgba(255, 59, 92, 0);
stroke: #18222e;
}
</style>