WalletCard
A stack of cards pulled apart by tap, with the chosen card lifted, and its number hidden until asked.
Demo
Tap to see your 4 cards
Choose a card to see its details.
Lifted: none
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/wallet_card.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/wallet_card.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/wallet_card.qwe
let wallet_card_demo_cards = [ { id: "visa", name: "Ana Reyes", number: "4242 4242 4242 4242", expiry: "04/28", color: "#27324b", nick: "Everyday" }, { id: "amex", name: "Ana Reyes", number: "378282246310005", expiry: "11/26", color: "#176d5a" }, { id: "mc", name: "Ana Reyes", number: "5555555555554444", expiry: "09/26", color: "#7a2e3a" }, { id: "server", name: "Ana Reyes", last4: "1117", brand: "Discover", expiry: "02/29", color: "#6a4a16" },]component WalletCardDemoLive() { state lifted = "none" <div.demo-stack> <WalletCard cards={wallet_card_demo_cards} today="2026-11-01" hide_after={3} id="wallet-demo" on_change={(c) => { lifted = c ?? "none" }} on_reveal={(c) => c.id == "amex" ? c.number : c.id == "server" ? js("Promise.resolve", js_new("Promise", (done) => { done() })) : js("Promise.resolve", js_new("Promise", (done) => { done(c.number) }))}/> <p.demo-note id="wallet-demo-lifted">Lifted: {lifted}</p> </div>}view WalletCardDemo() = <WalletCardDemoLive/>Source
ui/wallet_card.qwe
// WalletCard: payment cards in a stack, as in a phone's wallet. A tap pulls the stack// apart; a tap on a card lifts it above the others, with what it is and its number// hidden, which shows only when asked, for a set time, and goes when the app leaves the// screen. On a tablet the cards stand in a column with the chosen one's details beside them.//// import ui/wallet_card.qwe use WalletCard//// <WalletCard today="2026-11-01" cards={[// { id: "a", name: "A. Reyes", number: "4242424242424242", expiry: "04/28", color: "#27324b" },// { id: "b", name: "A. Reyes", last4: "1005", brand: "Amex", expiry: "11/26", color: "#176d5a" }]}/>//// cards: each { id, name, number or last4, expiry ("MM/YY"), brand, color, nick }.// number: the digits, spaces allowed. brand: what it says on the card; left out, it is// worked out from the number. color: the card's colour (dark, as the text is white).// nick: its own name ("Travel"). today: "YYYY-MM-DD", from your page, to mark a card past// its date. label: what screen readers call it ("Your cards"). hide_after: seconds the number// stays shown (30). id: a prefix for its ids ("ui-walcard"). on_change(id): the card lifted,// null when none is. on_reveal(card): gives the number to show, and may wait for a// server fn; an error value or null shows a message.//// A number passed in `cards` is in the page's HTML, as any prop is. For a real card pass// last4, and give the number from on_reveal, after your own check of who is asking.//// wallet_card_masked(number) and wallet_card_group(number) write a number as the card does.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet wallet_card_css = css`.ui-walcard { display: grid; gap: 14px; min-width: 0; container-type: inline-size; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); --ui-walcard-w: min(100cqw, 380px); --ui-walcard-h: calc(var(--ui-walcard-w) / 1.586); }.ui-walcard-deck { position: relative; width: var(--ui-walcard-w); max-width: 100%; margin: 0 auto; padding: 0; list-style: none; height: calc(var(--ui-walcard-h) + (var(--ui-walcard-n) - 1) * 44px); transition: height .26s cubic-bezier(.2, .8, .2, 1);}.ui-walcard-apart { height: calc(var(--ui-walcard-n) * (var(--ui-walcard-h) + 12px)); }.ui-walcard-lifted { height: calc(var(--ui-walcard-h) + 24px + (var(--ui-walcard-n) - 1) * 10px); }.ui-walcard-item { position: absolute; inset: 0 0 auto 0; z-index: var(--ui-walcard-i); transform: translateY(calc(var(--ui-walcard-i) * 44px)); transition: transform .26s cubic-bezier(.2, .8, .2, 1);}.ui-walcard-apart .ui-walcard-item { transform: translateY(calc(var(--ui-walcard-i) * (var(--ui-walcard-h) + 12px))); }/* The others go behind the one lifted, their edges showing under it; the row of buttons reaches them. */.ui-walcard-lifted .ui-walcard-item { z-index: 1; transform: translateY(calc(24px + var(--ui-walcard-r) * 10px)) scale(.94); }.ui-walcard-lifted .ui-walcard-chosen { z-index: 40; transform: translateY(0); }.ui-walcard-face { box-sizing: border-box; display: grid; grid-template-rows: auto 1fr auto; width: 100%; height: var(--ui-walcard-h); padding: 14px 16px; border: 0; border-radius: 14px; background: var(--ui-walcard-c, #2b2a3a); color: #fff; font: 14px/1.3 var(--ui-font); text-align: left; cursor: pointer; box-shadow: 0 2px 10px rgba(15, 14, 24, .28); touch-action: manipulation; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; background-image: linear-gradient(135deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, 0) 55%);}.ui-walcard-face:focus-visible { outline: 3px solid var(--ui-accent); outline-offset: 2px; }.ui-walcard-chosen .ui-walcard-face { box-shadow: 0 12px 28px rgba(15, 14, 24, .4); }.ui-walcard-top { display: flex; justify-content: space-between; gap: 8px; font-weight: 600; }.ui-walcard-digits { align-self: center; font: 500 17px/1.2 var(--ui-mono); letter-spacing: .04em; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-walcard-low { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; opacity: .92; }.ui-walcard-tag { padding: 0 6px; border-radius: 999px; background: #fff; color: #b42318; font-weight: 700; }.ui-walcard-bar { display: flex; justify-content: center; }.ui-walcard-detail { display: grid; gap: 10px; min-width: 0; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-walcard-detail h3 { margin: 0; font-size: 16px; }.ui-walcard-facts { display: grid; grid-template-columns: auto 1fr; gap: 2px 14px; margin: 0; }.ui-walcard-facts dt { color: var(--ui-muted); }.ui-walcard-facts dd { margin: 0; overflow-wrap: anywhere; }.ui-walcard-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ui-walcard-time, .ui-walcard-bad { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-walcard-bad { color: var(--ui-danger); }.ui-walcard-others { display: flex; flex-wrap: wrap; gap: 6px; }.ui-walcard-other { min-height: 44px; min-width: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px var(--ui-font); cursor: pointer; touch-action: manipulation;}.ui-walcard-other:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-walcard-choose { display: none; margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-walcard-hint { margin: 0; color: var(--ui-muted); font-size: 13px; text-align: center; }@container (min-width: 620px) { .ui-walcard-body { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 24px; align-items: start; } .ui-walcard-deck, .ui-walcard-apart, .ui-walcard-lifted { display: grid; gap: 10px; width: 100%; height: auto; } .ui-walcard-item, .ui-walcard-apart .ui-walcard-item, .ui-walcard-lifted .ui-walcard-item, .ui-walcard-lifted .ui-walcard-chosen { position: static; transform: none; } .ui-walcard-face { height: 150px; } .ui-walcard-chosen .ui-walcard-face { outline: 3px solid var(--ui-accent); outline-offset: 2px; } .ui-walcard-bar, .ui-walcard-hint, .ui-walcard-others { display: none; } .ui-walcard-choose { display: block; }}@media (prefers-reduced-motion: reduce) { .ui-walcard-deck, .ui-walcard-item { transition: none; } }`fn wallet_card_digits(number) -> str = re.replace(slice(str(number ?? ""), 0, 40), "[^0-9]", "")// The groups a number is printed in: American Express's 15 digits are 4, 6 and 5.fn wallet_card_sizes(digits: str) -> list = len(digits) == 15 && (starts_with(digits, "34") || starts_with(digits, "37")) ? [4, 6, 5] : range(0, floor((len(digits) + 3) / 4)) | map((_i) => 4)// Text cut into groups of those sizes.fn wallet_card_cut(text: str, sizes: list) -> str { let at = sizes | reduce([0], (a, n) => a | push(a[len(a) - 1] + n)) sizes | enumerate() | map((p) => slice(text, at[p[0]], at[p[0] + 1])) | filter((s) => s != "") | join(" ")}fn wallet_card_group(number) -> str { let d = wallet_card_digits(number) wallet_card_cut(d, wallet_card_sizes(d))}// All but the last four as dots, in the same groups.fn wallet_card_masked(number) -> str { let d = wallet_card_digits(number) let keep = slice(d, -4, len(d)) wallet_card_cut(repeat("•", max([0, len(d) - 4])) + keep, wallet_card_sizes(d))}// The name printed on a card, from the first digits.fn wallet_card_brand(number) -> str { let d = wallet_card_digits(number) let two = int(slice(d, 0, 2) + "0") / 10 let four = len(d) >= 4 ? int(slice(d, 0, 4)) : 0 if starts_with(d, "34") || starts_with(d, "37") { return "Amex" } if starts_with(d, "4") { return "Visa" } if (two >= 51 && two <= 55) || (four >= 2221 && four <= 2720) { return "Mastercard" } if starts_with(d, "6011") || starts_with(d, "65") { return "Discover" } "Card"}// Whether a card is past its date: it runs to the end of the month printed. A date that// cannot be read is not called expired.fn wallet_card_expired(expiry, today) -> bool { if type_of(expiry) != "str" || type_of(today) != "str" || !date.valid(today, "date") { return false } let m = re.find(expiry, "^([0-9]{2})/([0-9]{2})$") if m == null { return false } let month = int(m.groups[0]) if month < 1 || month > 12 { return false } let last = date.end_of_month(date.of(2000 + int(m.groups[1]), month, 1)) date.days_between(last, today) > 0}// The cards that can be shown, each with what the markup needs.fn wallet_card_clean(cards) -> list { if type_of(cards) != "list" { return [] } cards | filter((c) => type_of(c) == "map" && get(c, "id") != null) | map((c) => { let number = get(c, "number") let last4 = number != null ? slice(wallet_card_digits(number), -4, len(wallet_card_digits(number))) : str(get(c, "last4") ?? "") { id: str(c.id), name: str(get(c, "name") ?? ""), number: number, last4: last4, expiry: get(c, "expiry"), color: get(c, "color"), nick: get(c, "nick"), brand: get(c, "brand") ?? (number != null ? wallet_card_brand(number) : "Card"), face: number != null ? wallet_card_masked(number) : `•••• ${last4}` } })}// A card's place among the others, when one is lifted: 0 for the first behind it.fn wallet_card_rank(i: int, chosen: int) -> int = i < chosen ? i : i - 1view WalletCardFace(card: map, shown: str?, expired: bool, label: str, lifted: bool, on_tap) = <button.ui-walcard-face type="button" aria-label={label} aria-expanded={lifted ? "true" : "false"} style={card.color != null ? `--ui-walcard-c: ${card.color}` : null} @click={() => { on_tap() }}> <span.ui-walcard-top><span>{card.nick ?? card.brand}</span>{expired && <span.ui-walcard-tag>Expired</span>}</span> <span.ui-walcard-digits>{shown ?? card.face}</span> <span.ui-walcard-low><span>{card.name}</span><span>{card.expiry ?? ""}</span></span> </button>component WalletCard(cards = [], today: str? = null, label: str = "Your cards", hide_after: int = 30, id: str = "ui-walcard", on_change = null, on_reveal = null) { style global wallet_card_css let rows = wallet_card_clean(cards) state mode = "stack" // "stack", "apart" or "lifted" state chosen = null // the id of the card lifted state number = null // the number showing, only while it shows state left = 0 // seconds it has left state asking = false // waiting for on_reveal state failed = false state root = null let at = chosen != null ? rows | map((c) => c.id) | index_of(chosen) : null let card = at != null ? rows[at] : null let hide = () => { number = null left = 0 failed = false } if left > 0 { every(1000, () => { left -= 1 if left <= 0 { hide() } }) } let lift = (c) => { hide() chosen = c mode = c != null ? "lifted" : mode on_change && on_change(c) } let put_back = () => { if chosen != null { hide() chosen = null mode = "apart" on_change && on_change(null) } else { mode = "stack" } } let tapped = (c) => { let wide = root.clientWidth >= 620 if mode == "stack" && !wide { mode = "apart" } else if chosen == c.id { put_back() } else { lift(c.id) } } let got = (r = null) => { asking = false if r == null || is_err(r) { failed = true } else { number = wallet_card_group(r) left = hide_after } } let reveal = () => { if on_reveal != null { asking = true failed = false let r = on_reveal(card) if type_of(r) == "promise" { r.then(got, (_e = null) => { got(null) }) } else { got(r) } } else { got(card.number) } } <div.ui-walcard role="group" aria-label={label} style={`--ui-walcard-n: ${len(rows)}`} @mount={(el) => { // Gone from the screen, the app switcher shows what was left on it. let d = js("document") root = el let away = (_e = null) => { if d.visibilityState == "hidden" { hide() } } d.addEventListener("visibilitychange", away) () => d.removeEventListener("visibilitychange", away) }} @keydown={(e) => { if e.key == "Escape" && mode != "stack" { e.preventDefault() put_back() } }}> {len(rows) == 0 ? <p.ui-walcard-hint>No cards yet.</p> : <div.ui-walcard-body> <div> <ul class={ui_cls(["ui-walcard-deck", mode == "apart" && "ui-walcard-apart", mode == "lifted" && "ui-walcard-lifted"])}> {for [i, c] in enumerate(rows) => <li key={c.id} class={ui_cls(["ui-walcard-item", chosen == c.id && "ui-walcard-chosen"])} style={`--ui-walcard-i: ${i}; --ui-walcard-r: ${wallet_card_rank(i, at ?? 0)}`}> <WalletCardFace card={c} shown={chosen == c.id ? number : null} expired={wallet_card_expired(c.expiry, today)} lifted={chosen == c.id} label={`${c.nick ?? c.brand}, ending ${c.last4}${mode == "stack" ? ". Pull the cards apart" : ""}`} on_tap={() => { tapped(c) }}/> </li>} </ul> {mode == "stack" && <p.ui-walcard-hint>Tap to see your {len(rows)} cards</p>} {mode == "apart" && <div.ui-walcard-bar><Button variant="ghost" size="lg" on_click={put_back}>Put them away</Button></div>} </div> {card != null && <section.ui-walcard-detail aria-label={`${card.brand} ending ${card.last4}`}> <h3>{card.nick ?? card.brand}</h3> <dl.ui-walcard-facts> <dt>Number</dt><dd id={`${id}-number`} aria-live="polite">{number ?? card.face}</dd> <dt>Name</dt><dd>{card.name}</dd> <dt>Expires</dt><dd>{card.expiry ?? "Not given"}{wallet_card_expired(card.expiry, today) ? " (expired)" : ""}</dd> </dl> <div.ui-walcard-acts> {(card.number != null || on_reveal != null) && (number == null ? <Button variant="outline" size="lg" loading={asking} on_click={reveal}>Show number</Button> : <Button variant="outline" size="lg" on_click={hide}>Hide number</Button>)} <Button variant="ghost" size="lg" on_click={put_back}>All cards</Button> </div> {len(rows) > 1 && <div.ui-walcard-others role="group" aria-label="Other cards"> {for c in rows | filter((x) => x.id != card.id) => <button.ui-walcard-other key={c.id} type="button" @click={() => { lift(c.id) }}>{c.nick ?? c.brand} {c.last4}</button>} </div>} {number != null && <p.ui-walcard-time>Hides in {left} s</p>} {failed && <p.ui-walcard-bad role="alert">The number could not be shown. Try again.</p>} {card.number == null && on_reveal == null && <p.ui-walcard-time>Only the last four digits are kept on this device.</p>} </section>} {card == null && <p.ui-walcard-choose>Choose a card to see its details.</p>} </div>} </div>}Related
SwipeDeckA stack of cards flung left or right with a turn of the wrist, undo, and buttons that do the same.3 views · 9 downloadsTicketWalletTickets stacked by date with past ones kept apart, each code at full size and full brightness, and ready with no network.3 views · 6 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 7 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloads
Thread
Thread 0 posts
On WalletCard: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.