Gestures

Solid 2Unstable

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.

Live demo

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.

examples/notifications/Demo.tsx
import type { Notice } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, ward, wardic } from "@wardic/solid/unstable/v2"
import { revalidate } from "@solidjs/router"
import { action, type Accessor, createEffect, createMemo, createOptimistic, createSignal, For, Loading, refresh, Show, type SourceAccessor } from "solid-js"
import { notices } from "~/lib/store.ts"
import { dismissNotice, restoreNotices } from "~/server/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() {
  const list = createMemo(() => notices())
  const [problem, setProblem] = createSignal<string>()

  const restore = action(function* () {
    setProblem(undefined)
    yield restoreNotices()
    revalidate(notices.key)
  })

  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>
      <Loading fallback={<p class="muted">Loading your notifications…</p>}>
        {/* 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={list()} keyed={(notice) => notice.id}>
              {(notice) => <NoticeRow notice={notice} list={list} onProblem={setProblem} />}
            </For>
          </AnimatePresence>
        </ul>
        <Show when={list().length === 0}>
          <p class="muted notices-empty">You're all caught up.</p>
        </Show>
      </Loading>
      <Show when={problem()}>
        {(message) => (
          <p class="error-line" role="alert">
            {message()}
          </p>
        )}
      </Show>
    </div>
  )
}

function NoticeRow(props: { notice: Accessor<Notice>; list: SourceAccessor<ReadonlyArray<Notice>>; onProblem: (message: string) => void }) {
  // Optimistic: shown at once, and back to nothing when the action ends, whichever way it ends.
  // Where the card is thrown, and "Undo".
  const [out, setOut] = createOptimistic<Side | undefined>(undefined)
  const [undoable, setUndoable] = createOptimistic(false)
  // The side it leaves by, once the store has it: its row then goes, and the card stays off.
  const [gone, setGone] = createSignal<Side>("right")
  // The card's ward, made here with what reads it (`next`, `when`), and given to its element (#349).
  // It holds what it hears, the drag; the card's own props say where it flies (#398).
  const card = ward({ drag: "x", dragSnapToOrigin: true })

  // A gesture, its animation and a request, as one sequence: the request goes only once the card
  // has landed off to the side and the undo window has passed. An undo, or a store that refuses,
  // ends the action, and the card flies back.
  let undoNow = () => {}
  const dismiss = action(function* (side: Side) {
    setOut(side)
    yield card.next("settled")
    setUndoable(true)
    const undone: boolean = yield new Promise<boolean>((resolve) => {
      undoNow = () => resolve(true)
      setTimeout(() => resolve(false), UNDO_MS)
    })
    if (undone) return
    yield dismissNotice(props.notice().id)
    setGone(side)
    // The list without it, read in this action: the row goes as the action ends.
    revalidate(notices.key)
    refresh(props.list)
  })

  // Each release, through `when` (#355): an effect that throws the card on a fling, from the first
  // release on, on any page.
  createEffect(card.when("dragEnd"), (release) => {
    const flung = Math.abs(release.offset.x) >= FLING_OFFSET || Math.abs(release.velocity.x) >= FLING_VELOCITY
    if (flung) dismiss(sideOf(release.offset.x)).catch(() => props.onProblem("Couldn't dismiss it. It's back."))
  })

  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={() => undoNow()}>
              Undo
            </button>
          </wardic.p>
        </Show>
      </AnimatePresence>
      <wardic.article
        class={["notice", { out: out() !== undefined }]}
        data-kind={props.notice().kind}
        wardRef={card}
        variants={sides}
        initial="shown"
        animate={variantOf(out())}
        exit={gone()}
      >
        <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)
}
examples/notifications/Demo.tsx
// 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()} />
}
examples/notifications/Client.tsx
"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&apos;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)
}
Run Motion's version →