Mobile

WalletCard

A stack of cards pulled apart by tap, with the chosen card lifted, and its number hidden until asked.

#mobile#interactive#finance#cards#wallet#tablet

Demo

Preview

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.qwe
main.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>}
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.