Mobile

SwipeDeck

A stack of cards flung left or right with a turn of the wrist, undo, and buttons that do the same.

#mobile#interactive#gesture#swipe#cards#tablet

Demo

Preview

Lisbon

Portugal, three nights

5 of 5 left.

Keep

    Skip

      Nothing chosen yet.

      Get it

      It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

      $ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/swipe_deck.qwe
      main.qwe
      import ui/theme.qwe use theme_cssuse ui/swipe_deck.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

      Example

      The code of the preview above.

      demos/swipe_deck.qwe
      let swipe_deck_demo_cards = [    { id: "lis", title: "Lisbon", subtitle: "Portugal, three nights", color: "linear-gradient(135deg, #f6b26b, #e06666)" },    { id: "osl", title: "Oslo", subtitle: "Norway, a long weekend", color: "linear-gradient(135deg, #6fa8dc, #3d5a98)" },    { id: "kyo", title: "Kyoto", subtitle: "Japan, a week", color: "linear-gradient(135deg, #b6d7a8, #38761d)" },    { id: "lim", title: "Lima", subtitle: "Peru, ten days", color: "linear-gradient(135deg, #ffd966, #bf9000)" },    { id: "cpt", title: "Cape Town", subtitle: "South Africa, two weeks", color: "linear-gradient(135deg, #b4a7d6, #674ea7)" },]component SwipeDeckDemoLive() {    state log = "Nothing chosen yet."    state over = false    state cards = swipe_deck_demo_cards    <div.demo-stack>        <SwipeDeck cards={cards} left_label="Skip" right_label="Keep" height={340} label="Trips"                   on_swipe={(id, way) => { log = `${way}: ${id}` }}                   on_undo={(id) => { log = `undo: ${id}`; over = false }}                   on_empty={() => { over = true }}/>        <p.demo-note id="swipe-deck-log">{log}{over ? " All done." : ""}</p>        // For the test, as live data does in mid-drag: the cards changing, and one arriving at the front.        <button id="swipe-deck-again" type="button" hidden="true" @click={() => { cards = cards | map((c) => { ...c, subtitle: `${c.subtitle}, updated` }) }}>Again</button>        <button id="swipe-deck-front" type="button" hidden="true" @click={() => { cards = [{ id: "new", title: "Reykjavik", subtitle: "Iceland, a week", color: "linear-gradient(135deg, #a2c4c9, #134f5c)" }] + cards }}>Front</button>        // Bad data, for the test to look at: nothing here should stop the page.        <div id="swipe-deck-bad" hidden="true"><SwipeDeck cards={[null, "x", { id: 7 }, { id: 2, title: "One" }, { id: 2, title: "Again" }, { title: "No id" }]}/><SwipeDeck cards="none"/></div>    </div>}view SwipeDeckDemo() = <SwipeDeckDemoLive/>

      Source

      ui/swipe_deck.qwe
      // SwipeDeck: a stack of cards to go through one at a time, each flung left or right, as// in a dating or a flashcard app. The top card follows the finger and turns as it goes,// a word on it says which way it is heading, and it leaves when it is past 38% of the// deck's width or thrown fast enough; short of that it springs back. Undo takes back as many// as were made, last first. The buttons and the arrow keys do all of it without a swipe,// and in a wide container the choices made stand in two lists beside the deck.////   component Picks() {//       state kept = []//       <SwipeDeck cards={places} left_label="Skip" right_label="Keep"//                  on_swipe={(id, way) => { if way == "right" { kept = kept + [id] } }}/>//   }//// cards: each { id, title, subtitle?, body?, image?, alt?, color? }: image is an address, and// color any CSS colour behind a card without one. A card without a title is left out, and// a second card with an id already seen too. left_label, right_label: the words for each// way, on the card and the buttons. height: the stack's height in pixels (380).// on_swipe(id, way): inside one of your components, a card has gone, "left" or "right".// on_undo(id): that card came back. on_empty(): the last card went. label: the deck's name// for screen readers. empty_text: what shows when none are left. class.import ui/theme.qwe use ui_clslet swipe_deck_css = css`.ui-swipe-deck { container-type: inline-size; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }.ui-swipe-deck-inner { display: grid; gap: 14px; }.ui-swipe-deck-stack { position: relative; width: 100%; max-width: 420px; margin: 0 auto; touch-action: pan-y; outline: none; border-radius: calc(var(--ui-radius) + 4px); overscroll-behavior-x: contain; }.ui-swipe-deck-stack:focus-visible { box-shadow: var(--ui-focus); }/* The slot takes its place in the stack from markup, the card its movement from the finger: a style attribute is   rewritten whole when what it reads changes, so on one element a card arriving would throw a held card home. */.ui-swipe-deck-slot { position: absolute; inset: 0 0 20px 0; z-index: calc(3 - var(--i));  transform: translateY(calc(var(--i) * 9px)) scale(calc(1 - var(--i) * .04)); transform-origin: 50% 100%; transition: transform .24s cubic-bezier(.2, .8, .2, 1); }.ui-swipe-deck-card { position: absolute; inset: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ui-border);  border-radius: calc(var(--ui-radius) + 4px); background: var(--ui-surface); box-shadow: var(--ui-shadow);  transform-origin: 50% 100%; transition: transform .24s cubic-bezier(.2, .8, .2, 1);  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-swipe-deck-moving .ui-swipe-deck-top { transition: none; }.ui-swipe-deck-flying .ui-swipe-deck-top { transition: transform .22s ease-in; }.ui-swipe-deck-top { cursor: grab; }.ui-swipe-deck-moving .ui-swipe-deck-top { cursor: grabbing; }.ui-swipe-deck-back-left { animation: ui-swipe-deck-back-left .26s cubic-bezier(.2, .8, .2, 1); }.ui-swipe-deck-back-right { animation: ui-swipe-deck-back-right .26s cubic-bezier(.2, .8, .2, 1); }@keyframes ui-swipe-deck-back-left { from { transform: translateX(-120%) rotate(-12deg); } }@keyframes ui-swipe-deck-back-right { from { transform: translateX(120%) rotate(12deg); } }.ui-swipe-deck-pic { flex: 1; min-height: 0; background: var(--c, var(--ui-subtle)); }.ui-swipe-deck-pic img { display: block; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; pointer-events: none; }.ui-swipe-deck-text { flex: none; padding: 12px 16px 14px; }.ui-swipe-deck-text h3 { margin: 0; font-size: 19px; line-height: 1.25; }.ui-swipe-deck-text p { margin: 2px 0 0; color: var(--ui-muted); font-size: 14px; }.ui-swipe-deck-stamp { position: absolute; top: 18px; padding: 2px 10px; border: 3px solid currentColor; border-radius: var(--ui-radius-sm); background: var(--ui-surface);  font-size: 20px; font-weight: 700; opacity: 0; pointer-events: none; }.ui-swipe-deck-stamp-right { left: 16px; color: var(--ui-success); transform: rotate(-10deg); opacity: var(--right, 0); }.ui-swipe-deck-stamp-left { right: 16px; color: var(--ui-danger); transform: rotate(10deg); opacity: var(--left, 0); }.ui-swipe-deck-none { position: absolute; inset: 0 0 20px 0; display: grid; place-items: center; padding: 20px; border: 1px dashed var(--ui-border);  border-radius: calc(var(--ui-radius) + 4px); color: var(--ui-muted); text-align: center; }.ui-swipe-deck-bar { display: flex; justify-content: center; gap: 10px; }.ui-swipe-deck-btn { min-width: 76px; min-height: 48px; padding: 0 16px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface);  color: var(--ui-fg); font: 600 15px/1 var(--ui-font); cursor: pointer; }.ui-swipe-deck-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-swipe-deck-btn[aria-disabled="true"] { color: var(--ui-muted); opacity: .6; cursor: default; }.ui-swipe-deck-left { color: var(--ui-danger); }.ui-swipe-deck-right { color: var(--ui-success); }.ui-swipe-deck-count { margin: 0; color: var(--ui-muted); font-size: 13px; text-align: center; }.ui-swipe-deck-side { display: none; }.ui-swipe-deck-side h4 { margin: 0 0 4px; font-size: 13px; color: var(--ui-muted); text-transform: uppercase; letter-spacing: .05em; }.ui-swipe-deck-side ul { margin: 0 0 14px; padding: 0; list-style: none; }.ui-swipe-deck-side li { padding: 3px 0; border-bottom: 1px solid var(--ui-border); }@media (hover: hover) { .ui-swipe-deck-btn:not([aria-disabled="true"]):hover { background: var(--ui-subtle); } }@container (min-width: 560px) {  .ui-swipe-deck-inner { grid-template-columns: minmax(0, 1fr) 200px; grid-template-rows: auto auto auto; column-gap: 28px; }  .ui-swipe-deck-side { display: block; grid-column: 2; grid-row: 1 / span 3; }}@media (prefers-reduced-motion: reduce) {  .ui-swipe-deck-slot, .ui-swipe-deck-card, .ui-swipe-deck-flying .ui-swipe-deck-top { transition: none; }  .ui-swipe-deck-back-left, .ui-swipe-deck-back-right { animation: none; }}`// The cards that can be shown: those with a title, each id once, ids as text.fn swipe_deck_clean(cards) {    if type_of(cards) != "list" { return [] }    let named = cards | filter((c) => type_of(c) == "map" && get(c, "title") != null)    // One without an id gets its place, marked so it cannot be taken for a real id of that number.    let all = enumerate(named) | map((p) => { ...p[1], id: get(p[1], "id") != null ? str(p[1].id) : `_${p[0]}` })    let ids = all | map((c) => c.id)    enumerate(all) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}fn swipe_deck_axis(dx: num, dy: num, slop: num) -> str {    if abs(dx) < slop && abs(dy) < slop { return "" }    abs(dx) > abs(dy) ? "x" : "y"}// How far the card turns, in degrees, for a card moved dx: 12 at a full width, 16 at most.fn swipe_deck_tilt(dx: num, width: num) -> num {    if width <= 0 { return 0 }    let turn = dx / width * 12    round(max([-16, min([16, turn])]), 2)}// Where a release at dx with speed v (pixels a millisecond, signed) sends the card: "left",// "right", or "" to spring back. A throw counts from a small distance, one against the way// the card has gone takes it back, and otherwise it must be past 38% of the width.fn swipe_deck_decide(dx: num, v: num, width: num) -> str {    let way = dx >= 0 ? "right" : "left"    if abs(v) >= 0.5 && abs(dx) >= 24 { return v * dx > 0 ? way : "" }    abs(dx) >= width * 0.38 ? way : ""}fn swipe_deck_sample(s, pos: num, now: num) {    let dt = now - s.at    dt < 32 ? s : { pos, at: now, v: (pos - s.pos) / dt }}// The top card's element. This and the two below are top-level fns, not the component's// own `let` functions: one of those that calls a method of the page, or a prop, gives a// Promise, and a handler calling it would go on late, after the next click had come.fn swipe_deck_top(stack) = stack.querySelector(".ui-swipe-deck-top")// A held card drawn x, y from home, tilted, its stamp showing as far as it has gone.fn swipe_deck_hold(stack, card, x: num, y: num) {    if card != null {        let w = stack.offsetWidth        card.style.transform = x != 0 || y != 0 ? `translate(${x}px, ${y}px) rotate(${swipe_deck_tilt(x, w)}deg)` : ""        let p = w > 0 ? min([1, abs(x) / (w * 0.38)]) : 0        card.style.setProperty("--right", x > 0 ? `${p}` : "0")        card.style.setProperty("--left", x < 0 ? `${p}` : "0")    }}// on_swipe and on_empty, for a card that has landed: { id, way, rest }, or null for none.fn swipe_deck_tell(landed, on_swipe, on_empty) {    if landed != null {        on_swipe && on_swipe(landed.id, landed.way)        if landed.rest == 0 { on_empty && on_empty() }    }}// The speed at release, signed: none if the finger stopped before it lifted.fn swipe_deck_speed(s, pos: num, now: num, moved: num) -> num {    if now - moved > 80 { return 0 }    let dt = now - s.at    let since = dt >= 8 ? (pos - s.pos) / dt : 0    abs(since) > abs(s.v) ? since : s.v}component SwipeDeck(cards = [], left_label: str = "Skip", right_label: str = "Keep", height: int = 380, label: str = "Cards",                    empty_text: str = "No more cards.", on_swipe = null, on_undo = null, on_empty = null, class: str? = null) {    style global swipe_deck_css    let deck = swipe_deck_clean(cards)    state history = []       // what was done, oldest first: { id, way, title }    state moving = false    state flying = null      // the way the top card is leaving    state back = null        // { id, way } of a card just brought back, for its way in    state said = ""    state stack = null    state g = null    let done = history | map((h) => h.id)    let waiting = deck | filter((c) => !contains(done, c.id))    let shown = take(waiting, 3)    // The card in flight lands. It calls nothing but builtins, so it is done at once and what    // follows it sees the deck as it now is; swipe_deck_tell gives the props the news.    let finish = () => {        if flying != null && len(waiting) > 0 {            let c = waiting[0]            let way = flying            let rest = len(waiting) - 1     // now: waiting follows history as soon as that is set            flying = null            history = history + [{ id: c.id, way, title: c.title }]            said = `${way == "right" ? right_label : left_label}: ${c.title}. ${rest} left.`            return { id: c.id, way, rest }        }        null    }    let fling = (way: str) => {        swipe_deck_tell(finish(), on_swipe, on_empty)        if len(waiting) > 0 {            moving = false            flying = way            let w = stack.offsetWidth * 1.5            let card = swipe_deck_top(stack)            if card != null {                card.style.transform = `translate(${way == "right" ? w : -w}px, 30px) rotate(${way == "right" ? 18 : -18}deg)`            }        }    }    let undo = () => {        swipe_deck_tell(finish(), on_swipe, on_empty)        if len(history) > 0 {            let h = history[len(history) - 1]            back = { id: h.id, way: h.way }            history = slice(history, 0, len(history) - 1)            said = `Back: ${h.title}. ${len(waiting)} left.`            on_undo && on_undo(h.id)        }    }    if flying != null { every(210, () => { swipe_deck_tell(finish(), on_swipe, on_empty) }) }    <div class={ui_cls(["ui-swipe-deck", class])}>        <div.ui-swipe-deck-inner>            <div class={ui_cls(["ui-swipe-deck-stack", moving && "ui-swipe-deck-moving", flying != null && "ui-swipe-deck-flying"])}                 style={`height: ${height}px`} tabindex="0" role="group" aria-label={label}                 aria-roledescription="deck of cards"                 @mount={(me) => { stack = me }}                 @keydown={(e) => {                     if e.key == "ArrowLeft" { e.preventDefault(); fling("left") }                     if e.key == "ArrowRight" { e.preventDefault(); fling("right") }                     if e.key == "Backspace" || (e.key == "z" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); undo() }                 }}                 @pointerdown={(e) => {                     g = e.isPrimary && e.button == 0 && flying == null && len(waiting) > 0                         ? { id: e.pointerId, x: e.clientX, y: e.clientY, on: false, dx: 0, moved: e.timeStamp, s: { pos: e.clientX, at: e.timeStamp, v: 0 } }                         : null                 }}                 @pointermove={(e) => {                     if g != null && e.pointerId == g.id {                         let dx = e.clientX - g.x                         let dy = e.clientY - g.y                         if !g.on {                             let way = swipe_deck_axis(dx, dy, 8)                             if way == "y" { g = null }                             if way == "x" {                                 stack.setPointerCapture(e.pointerId)                                 back = null                                 moving = true                                 // This card, whatever comes to stand on top of it while it is held.                                 g = { ...g, on: true, card: swipe_deck_top(stack) }                             }                         }                         if g != null && g.on {                             swipe_deck_hold(stack, g.card, dx, dy * 0.4)                             g = { ...g, dx, moved: e.timeStamp, s: swipe_deck_sample(g.s, e.clientX, e.timeStamp) }                         }                     }                 }}                 @pointerup={(e) => {                     if g != null && e.pointerId == g.id {                         let was = g                         g = null                         if was.on {                             let way = swipe_deck_decide(was.dx, swipe_deck_speed(was.s, e.clientX, e.timeStamp, was.moved), stack.offsetWidth)                             moving = false                             let still_top = was.card != null && swipe_deck_top(stack) != null && was.card.isSameNode(swipe_deck_top(stack))                             if way != "" && still_top { fling(way) } else { swipe_deck_hold(stack, was.card, 0, 0) }                         }                     }                 }}                 @pointercancel={(e) => {                     if g != null && e.pointerId == g.id {                         let was = g                         g = null                         moving = false                         if was.on { swipe_deck_hold(stack, was.card, 0, 0) }                     }                 }}>                {len(waiting) == 0 && <div.ui-swipe-deck-none>{empty_text}</div>}                {for [i, c] in enumerate(shown) => <div.ui-swipe-deck-slot key={c.id} style={`--i: ${i}`} aria-hidden={i > 0 ? "true" : null}><article                        class={ui_cls(["ui-swipe-deck-card", i == 0 && "ui-swipe-deck-top",                                       back != null && i == 0 && back.id == c.id && `ui-swipe-deck-back-${back.way}`])}>                    <div.ui-swipe-deck-pic style={get(c, "color") != null ? `--c: ${c.color}` : null}>                        {get(c, "image") != null && <img src={c.image} alt={get(c, "alt") ?? ""} draggable="false"/>}                    </div>                    <div.ui-swipe-deck-text>                        <h3>{c.title}</h3>                        {get(c, "subtitle") != null && <p>{c.subtitle}</p>}                        {get(c, "body") != null && <p>{c.body}</p>}                    </div>                    <span.ui-swipe-deck-stamp.ui-swipe-deck-stamp-right aria-hidden="true">{right_label}</span>                    <span.ui-swipe-deck-stamp.ui-swipe-deck-stamp-left aria-hidden="true">{left_label}</span>                </article></div>}            </div>            <div.ui-swipe-deck-bar>                <button.ui-swipe-deck-btn.ui-swipe-deck-left type="button" aria-disabled={len(waiting) == 0 ? "true" : null}                        @click={() => { if len(waiting) > 0 { fling("left") } }}>{left_label}</button>                <button.ui-swipe-deck-btn.ui-swipe-deck-undo type="button" aria-disabled={len(history) == 0 ? "true" : null}                        @click={() => { undo() }}>Undo</button>                <button.ui-swipe-deck-btn.ui-swipe-deck-right type="button" aria-disabled={len(waiting) == 0 ? "true" : null}                        @click={() => { if len(waiting) > 0 { fling("right") } }}>{right_label}</button>            </div>            <p.ui-swipe-deck-count role="status">{said != "" ? said : `${len(waiting)} of ${len(deck)} left.`}</p>            <div.ui-swipe-deck-side>                <h4>{right_label}</h4>                <ul.ui-swipe-deck-kept>{for h in history | filter((h) => h.way == "right") => <li key={h.id}>{h.title}</li>}</ul>                <h4>{left_label}</h4>                <ul.ui-swipe-deck-passed>{for h in history | filter((h) => h.way == "left") => <li key={h.id}>{h.title}</li>}</ul>            </div>        </div>    </div>}
      Thread
      39Useful? Like it, or add to its thread below.

      Thread 0 posts

      On SwipeDeck: questions, tips, corrections, and code that uses it.

      No posts yet. The first one opens the thread.