Gestures
Notifications you swipe away
Throw a notification aside and it flies off; for four seconds you can undo it, then it's gone and the rest close the gap.
Swipe one aside, either way.
Loading your notifications…
Reduced motion: The card still follows your finger; a throw sends it off at once, and the rows below move up at once.
Why
A swipe should feel like brushing a card off the table: it follows your finger sideways, and if you let go gently it springs back. Throw it, far enough or fast enough, and it keeps going the way you threw it, at the speed you threw it. Each card is draggable on x only, snapping back to where it was unless the release counts as a throw.
A dismissal you can't take back is unkind, so the request waits. The card flies off, "Dismissed · Undo" takes its place for four seconds, and only then is the store told. An undo, or a store that refuses, brings the card back the way it went.
When the store has it, the row leaves the list at once and the rows below glide up into the gap: the list is an AnimatePresence in popLayout mode, and each row a layout element. On Solid 2 the card's throw, its landing, the undo window and the request are one action, waiting on the card's next("settled"). The card's ward is made in its row, beside that action and the releases it reads, and given to the card's wardic.article through wardRef. It holds what it hears, the drag; the card's own variants, animate and exit say where it flies.
The code
Motion has no next("settled"). Its card says the throw has landed through onAnimationComplete, by the variant's name, which offers "Undo" and starts a timer; the request goes when the timer ends, as a server action that refreshes the page, and the row leaves with the list read again. Undo, or a refusal, clears the timer and flies the card back. On Solid 2 the throw, its landing, the undo window and the request are one action. The throw takes over from dragSnapToOrigin's spring back in both.
import { createAsyncStore, useAction } from "@solidjs/router"
import type { Notice } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, wardic, type WardicDragInfo } from "@wardic/solid"
import { createSignal, For, onCleanup, Show, Suspense } from "solid-js"
import { dismissNotice, notices, restoreNotices } from "~/lib/store.ts"
type Side = "left" | "right"
// A card rests in place, or has flown off to a side: from the throw, at its speed, and settled as
// soon as it's out of sight, so "Undo" comes up as it goes. Faded out by 0.2 s and past the edge, it
// can rest coarser than a spring's default (Motion's, 0.5 px and 2 px/s): "Undo" about 500 ms after
// the release, against 650.
const flyOut = { x: { type: "spring", stiffness: 300, damping: 35, restDelta: 1, restSpeed: 20 }, opacity: { type: "tween", duration: 0.2 } } as const
const sides = { shown: { x: 0, opacity: 1 }, left: { x: -420, opacity: 0, transition: flyOut }, right: { x: 420, opacity: 0, transition: flyOut } } as const
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const
/** Far enough, or fast enough: a release that throws the card away. */
const FLING_OFFSET = 120
const FLING_VELOCITY = 600
/** How long "Undo" is offered, once the card has flown off. */
const UNDO_MS = 4000
export default function Notifications() {
// A store, reconciled by id: a notice read again is the same row, so only the one that went leaves.
const list = createAsyncStore(() => notices(), { reconcile: { key: "id" } })
const restore = useAction(restoreNotices)
const [problem, setProblem] = createSignal<string>()
return (
<div class="notices">
<header class="notices-head">
<p class="muted">Swipe one aside, either way.</p>
<button class="button quiet" onClick={() => restore().catch((error: unknown) => setProblem(messageOf(error)))}>
Bring them back
</button>
</header>
<Suspense fallback={<p class="muted">Loading your notifications…</p>}>
<Show when={list()}>
{(shown) => (
<>
{/* A row that goes leaves the list at once, and the rows below close the gap. */}
<ul class="notice-list">
<AnimatePresence mode="popLayout" initial={false}>
<For each={shown()}>{(notice) => <NoticeRow notice={notice} onProblem={setProblem} />}</For>
</AnimatePresence>
</ul>
<Show when={shown().length === 0}>
<p class="muted notices-empty">You're all caught up.</p>
</Show>
</>
)}
</Show>
</Suspense>
<Show when={problem()}>
{(message) => (
<p class="error-line" role="alert">
{message()}
</p>
)}
</Show>
</div>
)
}
function NoticeRow(props: { notice: Notice; onProblem: (message: string) => void }) {
const dismiss = useAction(dismissNotice)
// Where the card is: set as it's thrown, and put back by an undo or a failure.
const [side, setSide] = createSignal<Side>()
const [undoable, setUndoable] = createSignal(false)
// The request waits for the undo window, which opens once the card has landed off to the side.
let wait: ReturnType<typeof setTimeout> | undefined
onCleanup(() => clearTimeout(wait))
const landed = (definition: unknown) => {
if (definition !== side()) return
setUndoable(true)
wait = setTimeout(() => {
dismiss(props.notice.id).catch(() => {
putBack()
props.onProblem("Couldn't dismiss it. It's back.")
})
}, UNDO_MS)
}
const putBack = () => {
clearTimeout(wait)
setUndoable(false)
setSide(undefined)
}
const released = (_event: PointerEvent, info: WardicDragInfo) => {
const flung = Math.abs(info.offset.x) >= FLING_OFFSET || Math.abs(info.velocity.x) >= FLING_VELOCITY
if (flung) setSide(sideOf(info.offset.x))
}
return (
<wardic.li class="notice-row" data-id={props.notice.id} layout initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<AnimatePresence>
<Show when={undoable()}>
<wardic.p class="notice-undo" {...fade}>
Dismissed ·{" "}
<button class="link-button" onClick={putBack}>
Undo
</button>
</wardic.p>
</Show>
</AnimatePresence>
<wardic.article
class="notice"
classList={{ out: side() !== undefined }}
data-kind={props.notice.kind}
drag="x"
dragSnapToOrigin
variants={sides}
initial="shown"
animate={variantOf(side())}
onDragEnd={released}
onAnimationComplete={landed}
>
<strong>{props.notice.title}</strong>
<span class="muted">{props.notice.body}</span>
</wardic.article>
</wardic.li>
)
}
function variantOf(side: Side | undefined): keyof typeof sides {
if (side === undefined) return "shown"
return side
}
function sideOf(offset: number): Side {
if (offset < 0) return "left"
return "right"
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
// Notifications you swipe away, a server component: it starts the list's read and hands its
// promise to the client part. A dismissal refreshes the page, which reads the list again.
import { getNotices } from "~/lib/store.ts"
import { Notifications } from "./Client.tsx"
export default function Demo() {
return <Notifications notices={getNotices()} />
}
"use client"
import type { Notice } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, motion } from "motion/react"
import { startTransition, Suspense, use, useEffect, useRef, useState } from "react"
import { dismissNotice, restoreNotices } from "~/lib/store.ts"
type Side = "left" | "right"
// A card rests in place, or has flown off to a side: from the throw, at its speed, and settled as
// soon as it's out of sight, so "Undo" comes up as it goes. Faded out by 0.2 s and past the edge, it
// can rest coarser than a spring's default (0.5 px and 2 px/s).
const flyOut = { x: { type: "spring", stiffness: 300, damping: 35, restDelta: 1, restSpeed: 20 }, opacity: { type: "tween", duration: 0.2 } } as const
const sides = { shown: { x: 0, opacity: 1 }, left: { x: -420, opacity: 0, transition: flyOut }, right: { x: 420, opacity: 0, transition: flyOut } } as const
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const
/** Far enough, or fast enough: a release that throws the card away. */
const FLING_OFFSET = 120
const FLING_VELOCITY = 600
/** How long "Undo" is offered, once the card has flown off. */
const UNDO_MS = 4000
export function Notifications({ notices }: { readonly notices: Promise<ReadonlyArray<Notice>> }) {
const [problem, setProblem] = useState<string>()
// A server action: it refreshes the page, which reads the list again.
const restore = () =>
startTransition(async () => {
setProblem(undefined)
try {
await restoreNotices()
} catch (error) {
setProblem(messageOf(error))
}
})
return (
<div className="notices">
<header className="notices-head">
<p className="muted">Swipe one aside, either way.</p>
<button className="button quiet" onClick={restore}>
Bring them back
</button>
</header>
<Suspense fallback={<p className="muted">Loading your notifications…</p>}>
<List notices={notices} onProblem={setProblem} />
</Suspense>
{problem && (
<p className="error-line" role="alert">
{problem}
</p>
)}
</div>
)
}
function List({ notices, onProblem }: { readonly notices: Promise<ReadonlyArray<Notice>>; readonly onProblem: (message: string) => void }) {
const list = use(notices)
return (
<>
{/* A row that goes leaves the list at once, and the rows below close the gap. */}
<ul className="notice-list">
<AnimatePresence mode="popLayout" initial={false}>
{list.map((notice) => (
<NoticeRow key={notice.id} notice={notice} onProblem={onProblem} />
))}
</AnimatePresence>
</ul>
{list.length === 0 && <p className="muted notices-empty">You're all caught up.</p>}
</>
)
}
function NoticeRow({ notice, onProblem }: { readonly notice: Notice; readonly onProblem: (message: string) => void }) {
// Where the card is thrown: it flies there, and stays there as its row goes.
const [out, setOut] = useState<Side>()
const [undoable, setUndoable] = useState(false)
const pending = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(pending.current), [])
// Undone, or refused by the store: "Undo" goes, and the card flies back.
const back = () => {
clearTimeout(pending.current)
setUndoable(false)
setOut(undefined)
}
// Landed off to the side: "Undo" for a while, then the store is told. Its answer refreshes the
// page, and the row goes, the card staying off.
const landed = () => {
setUndoable(true)
pending.current = setTimeout(
() =>
startTransition(async () => {
try {
await dismissNotice(notice.id)
} catch {
back()
onProblem("Couldn't dismiss it. It's back.")
}
}),
UNDO_MS,
)
}
return (
<motion.li className="notice-row" data-id={notice.id} layout initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<AnimatePresence>
{undoable && (
<motion.p className="notice-undo" {...fade}>
Dismissed ·{" "}
<button className="link-button" onClick={back}>
Undo
</button>
</motion.p>
)}
</AnimatePresence>
<motion.article
className={out ? "notice out" : "notice"}
data-kind={notice.kind}
drag="x"
dragSnapToOrigin
variants={sides}
initial="shown"
animate={out ?? "shown"}
exit={out}
// Each release: a fling throws the card, taking over from its spring back to the centre.
onDragEnd={(_event, { offset, velocity }) => {
const flung = Math.abs(offset.x) >= FLING_OFFSET || Math.abs(velocity.x) >= FLING_VELOCITY
if (flung && out === undefined) setOut(sideOf(offset.x))
}}
// Motion tells an animation's end by its target: a side is the throw landing.
onAnimationComplete={(definition) => {
if (definition === "left" || definition === "right") landed()
}}
>
<strong>{notice.title}</strong>
<span className="muted">{notice.body}</span>
</motion.article>
</motion.li>
)
}
function sideOf(offset: number): Side {
if (offset < 0) return "left"
return "right"
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}