Commerce

SubscriptionCard

The plan someone is on: price, status, renewal date, usage bars and the links to manage, change or cancel it.

#commerce#price#card#settings#dashboard#no-js

Demo

Preview

Pro

Active
$12/month
Renews 12 Nov 2026
Paid with Visa ending 4242
  • Projects7 of 10
  • Storage42.5 GB of 50 GB

Prices exclude VAT.

Team

Trial
$49/month
Trial ends Nov 20, 2026
  • Seats5 of 5

The links call on_change and on_cancel.

Hobby

Cancelled
$0/month
Ends 1 Dec 2026

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

Thread 0 posts

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

No posts yet. The first one opens the thread.