SubscriptionCard
The plan someone is on: price, status, renewal date, usage bars and the links to manage, change or cancel it.
Demo
Pro
Active$12/month
- Projects7 of 10
- Storage42.5 GB of 50 GB
Prices exclude VAT.
Team
Trial$49/month
- Seats5 of 5
The links call on_change and on_cancel.
Hobby
Cancelled$0/month
Get it
It needs theme.qwe, as every component does, and price.qwe; 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/price.qwe$ curl -O https://quillolang.org/components/ui/subscription_card.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/subscription_card.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/subscription_card.qwe
component SubscriptionCardDemoLive() { state did = null <div.demo-stack style="width: 340px"> <SubscriptionCard plan="Team" price={49} status="trialing" renews="2026-11-20" locale="en-US" usage={[{ label: "Seats", used: 5, limit: 5 }]} on_change={() => { did = "change" }} on_cancel={() => { did = "cancel" }}/> <p.demo-note>{did == null ? "The links call on_change and on_cancel." : `Clicked ${did}.`}</p> </div>}view SubscriptionCardDemo() = <div.demo-row style="align-items: flex-start; gap: 20px"> <div style="width: 340px"> <SubscriptionCard plan="Pro" price={12} status="active" renews="2026-11-12" payment="Visa ending 4242" manage_href="#subscription_card" change_href="#pricing_table" cancel_href="#subscription_card" usage={[{ label: "Projects", used: 7, limit: 10 }, { label: "Storage", used: 42.5, limit: 50, unit: " GB" }]} note="Prices exclude VAT."/> </div> <SubscriptionCardDemoLive/> <div style="width: 340px"> <SubscriptionCard plan="Hobby" price={0} status="cancelled" ends="2026-12-01" manage_href="#subscription_card"/> </div> </div>Source
ui/subscription_card.qwe
// SubscriptionCard: the plan someone is on: its name and price, whether it is// active, when it renews, how much of it is used, and the links to change or// cancel it. Prices are written as Price writes them (this file needs price.qwe).//// <SubscriptionCard plan="Pro" price={12} period="month" status="active" renews="2026-11-12"// payment="Visa ending 4242" manage_href="/billing" change_href="/billing/plan" cancel_href="/billing/cancel"// usage={[{ label: "Projects", used: 7, limit: 10 }, { label: "Storage", used: 32.5, limit: 50, unit: " GB" }]}/>//// plan: its name. price, period, currency, locale, cents (false): as for Price.// status: "active" (default), "trialing", "past_due", "paused" or "cancelled";// status_text: other words for it. renews: the next billing date (an ISO date),// written for locale ("Renews 12 Nov 2026"); for a trial, "Trial ends"; ends:// instead, when a cancelled plan stops. payment: how it is paid ("Visa ending// 4242"). usage: lines of { label, used, limit, unit? }, each a bar (red near the// limit). manage_href, change_href, cancel_href: the links ("Manage billing",// "Change plan", "Cancel"); on_manage(), on_change(), on_cancel() instead, inside// one of your components. note: a quieter line at the bottom.import ui/price.qwe use price_textimport ui/theme.qwe use ui_clslet subscription_card_css = css`.ui-sub { display: grid; gap: 14px; min-width: 0; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-sub-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 8px 16px; }.ui-sub-plan { display: flex; align-items: center; gap: 10px; }.ui-sub-name { margin: 0; font-size: 18px; font-weight: 600; }.ui-sub-status { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 9px; border-radius: 999px; font: 500 12px/1 var(--ui-font); --t: var(--ui-success); --ts: var(--ui-success-soft); color: var(--t); background: var(--ts); }.ui-sub-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--t); }.ui-sub-trialing { --t: var(--ui-info); --ts: var(--ui-info-soft); }.ui-sub-past_due { --t: var(--ui-danger); --ts: var(--ui-danger-soft); }.ui-sub-paused { --t: var(--ui-warning); --ts: var(--ui-warning-soft); }.ui-sub-cancelled { --t: var(--ui-muted); --ts: var(--ui-subtle); }.ui-sub-price { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }.ui-sub-price span { font-size: 13px; font-weight: 400; color: var(--ui-muted); }.ui-sub-meta { display: grid; gap: 2px; margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-sub-meta b { font-weight: 500; color: var(--ui-fg); }.ui-sub-usage { display: grid; gap: 10px; margin: 0; padding: 12px 0 0; border-top: 1px solid var(--ui-border); list-style: none; }.ui-sub-line { display: grid; gap: 5px; }.ui-sub-line-head { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }.ui-sub-line-head span:last-child { color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-sub-track { height: 6px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); }.ui-sub-fill { display: block; height: 100%; border-radius: inherit; background: var(--ui-accent); transition: width .3s; }.ui-sub-near .ui-sub-fill { background: var(--ui-warning); }.ui-sub-full .ui-sub-fill { background: var(--ui-danger); }.ui-sub-links { display: flex; flex-wrap: wrap; gap: 8px 18px; padding-top: 12px; border-top: 1px solid var(--ui-border); font-size: 13px; }.ui-sub-links a, .ui-sub-links button { padding: 0; border: 0; background: none; color: var(--ui-accent); font: inherit; font-weight: 500; text-decoration: none; cursor: pointer; border-radius: 3px; }.ui-sub-links a:hover, .ui-sub-links button:hover { text-decoration: underline; }.ui-sub-links a:focus-visible, .ui-sub-links button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-sub-links .ui-sub-cancel { color: var(--ui-danger); margin-left: auto; }.ui-sub-note { margin: 0; font-size: 12px; color: var(--ui-muted); }@media (prefers-reduced-motion: reduce) { .ui-sub-fill { transition: none; } }`let subscription_card_words = { active: "Active", trialing: "Trial", past_due: "Payment due", paused: "Paused", cancelled: "Cancelled" }// A number as text, whole ones without ".0".fn subscription_card_num(n: num) -> str = n == floor(n) ? str(floor(n)) : str(n)view SubscriptionCard(plan: str, price = null, period: str? = "month", currency: str = "USD", locale: str? = null, cents: bool = false, status: str = "active", status_text: str? = null, renews: str? = null, ends: str? = null, payment: str? = null, usage: list = [], manage_href: str? = null, change_href: str? = null, cancel_href: str? = null, note: str? = null, on_manage = null, on_change = null, on_cancel = null, class: str? = null) { style global subscription_card_css let when = (d: str) => date.format(d, { locale: locale ?? "en-GB", style: "medium" }) let link = (href: str?, f, text: str, cls: str?) => href != null ? <a href={href} class={cls}>{text}</a> : f != null ? <button type="button" class={cls} @click={() => { f() }}>{text}</button> : null <section class={ui_cls(["ui-sub", class])} aria-label={`${plan} subscription`}> <div.ui-sub-head> <div.ui-sub-plan> <h3.ui-sub-name>{plan}</h3> <span class={`ui-sub-status ui-sub-${status}`}>{status_text ?? get(subscription_card_words, status) ?? status}</span> </div> {price != null && <div.ui-sub-price>{price_text(price, currency, locale, cents)}{period != null && <span>/{period}</span>}</div>} </div> {(renews != null || ends != null || payment != null) && <dl.ui-sub-meta> {ends != null ? <div><b>Ends</b> {when(ends)}</div> : renews != null && <div><b>{status == "trialing" ? "Trial ends" : "Renews"}</b> {when(renews)}</div>} {payment != null && <div><b>Paid with</b> {payment}</div>} </dl>} {len(usage) > 0 && <ul.ui-sub-usage> {for u in usage => { let pct = u.limit > 0 ? u.used * 100 / u.limit : 0 let shown = pct > 100 ? 100 : pct <li.ui-sub-line key={u.label} class={ui_cls([pct >= 100 && "ui-sub-full", pct >= 80 && pct < 100 && "ui-sub-near"])}> <div.ui-sub-line-head> <span>{u.label}</span> <span>{subscription_card_num(u.used)}{get(u, "unit") ?? ""} of {subscription_card_num(u.limit)}{get(u, "unit") ?? ""}</span> </div> <div.ui-sub-track role="meter" aria-label={u.label} aria-valuemin="0" aria-valuemax={subscription_card_num(u.limit)} aria-valuenow={subscription_card_num(u.used)} aria-valuetext={`${subscription_card_num(u.used)}${get(u, "unit") ?? ""} of ${subscription_card_num(u.limit)}${get(u, "unit") ?? ""}`}> <span.ui-sub-fill style={`width: ${round(shown, 1)}%`}></span> </div> </li> }} </ul>} {(manage_href != null || change_href != null || cancel_href != null || on_manage != null || on_change != null || on_cancel != null) && <div.ui-sub-links> {link(manage_href, on_manage, "Manage billing", null)} {link(change_href, on_change, "Change plan", null)} {status != "cancelled" && link(cancel_href, on_cancel, "Cancel", "ui-sub-cancel")} </div>} {note != null && <p.ui-sub-note>{note}</p>} </section>}Related
PricingCardOne plan: name, price and period, what it includes, and a button; highlighted with a badge when it is the one to pick.7 views · 6 downloadsProductCardA product in a shop's grid: picture, name, price with the discount, stars, a badge and Add to cart, as a plain form or a callback.6 views · 6 downloadsProductGridProducts as ProductCards in a grid that fits as many across as there is room for, from a list of records.6 views · 5 downloadsAddressCardA saved address with a Default badge and Edit and Remove, as submit buttons in a plain form or callbacks.6 views · 7 downloadsCartItemOne line of a cart: picture, name and options, a quantity control, the line's total and Remove, as inputs a form sends.6 views · 6 downloadsCartSummarySubtotal, shipping, tax, a discount and the total added up, with a Checkout button as a link, a form or a callback.6 views · 7 downloads
Thread
Thread 0 posts
On SubscriptionCard: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.