Pricing plans
A source-copy offer comparison with consumer-owned prices, billing terms, actions, and optional controlled period selection.
Make offer choices clear without owning billingLink to section
PricingPlans composes public PyColors UI cards into a responsive comparison.
Each offer shows its name, description, formatted price, billing terms,
features and consumer-owned action. An optional native select requests a
billing-period change without calculating prices or choosing data for you.
The Block never creates a checkout session, subscribes a customer, calculates a discount, interprets currency, fetches offers or enforces access. Those are application responsibilities, not presentation behavior.
Fictional demonstration
The offers below are illustrative, not PyColors product prices. Changing a period or choosing an example changes local React state only. No purchase, contact request, account creation or network request occurs.
Explore the pricing pattern
Fictional offers only, not PyColors product prices. No purchase or network request occurs.
Launch
A fictional offer for a small project.
$12 / month
Fictional monthly charge. No purchase is available.
- One sample project
- Sample export tools
Studio
Featured exampleA fictional offer for a growing team.
$29 / month
Fictional monthly charge. No purchase is available.
- Ten sample projects
- Sample collaboration tools
- Sample export tools
Custom
An example of a non-numeric, consumer-owned price.
Let's talk
Scope and terms would be agreed separately.
- A tailored sample scope
This disabled action does not contact a team.
Choose an example to preview a consumer-owned action.
Canonical identityLink to section
- Category: Commerce, slug
commerce. - Block:
pricing-plans, exportPricingPlans. - Canonical source:
apps/marketing/content/blocks/commerce/pricing-plans/. - Complete entry point:
index.tsx. - Informational future Registry identity:
commerce-pricing-plans.
This identity does not provide a Registry installer. There is no Blocks npm package, CLI, download service or automatic source update channel.
Copy sourceLink to section
Configure the public UI package and tokens, then create
src/components/blocks/pricing-plans/index.tsx in your application.
Open the source below and use its copy button, or select the code manually.
This is the complete canonical file, not the usage example or a second
implementation. Keep the imports and client directive when copying.
View and copy the complete source
"use client";
import * as React from "react";
import {
Badge,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
cn,
} from "@pycolors/ui";
export type PricingPlanFeature = Readonly<{
id: string;
label: string;
}>;
export type PricingPlan = Readonly<{
id: string;
name: string;
description: string;
price: string;
priceSuffix?: string;
billingNote?: string;
highlight?: string;
features: readonly PricingPlanFeature[];
action: React.ReactNode;
footnote?: string;
}>;
export type PricingPeriodOption = Readonly<{
value: string;
label: string;
disabled?: boolean;
}>;
export type PricingPeriodControl = Readonly<{
label: string;
value: string;
options: readonly PricingPeriodOption[];
onValueChange: (value: string) => void;
disabled?: boolean;
}>;
export type PricingPlansProps = Readonly<{
title: string;
description?: string;
plans: readonly PricingPlan[];
period?: PricingPeriodControl;
emptyMessage?: string;
className?: string;
}>;
/** Present consumer-owned offers without price calculations or purchases. */
export function PricingPlans({
title,
description,
plans,
period,
emptyMessage = "No plans are available.",
className,
}: PricingPlansProps) {
const id = React.useId();
const titleId = `${id}-title`;
const descriptionId = `${id}-description`;
const periodId = `${id}-period`;
const selectedPeriod = period?.options.find(
(option) => option.value === period.value,
);
return (
<section
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
className={cn("min-w-0 space-y-6", className)}
data-slot="pricing-plans"
>
<div className="space-y-2">
<h2 className="break-words text-2xl font-semibold" id={titleId}>
{title}
</h2>
{description ? (
<p className="text-muted-foreground" id={descriptionId}>
{description}
</p>
) : null}
</div>
{plans.length === 0 ? (
<p className="text-muted-foreground" data-slot="pricing-plans-empty">
{emptyMessage}
</p>
) : (
<>
{period && period.options.length > 0 ? (
<div className="flex min-w-0 flex-wrap items-center gap-3">
<label className="text-sm font-medium" htmlFor={periodId}>
{period.label}
</label>
<select
className={cn(
"min-h-11 min-w-0 max-w-full rounded-md border",
"border-input bg-background px-3 py-2 text-sm",
"focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring disabled:opacity-50",
)}
disabled={period.disabled}
id={periodId}
onChange={(event) => {
const next = period.options.find(
(option) => option.value === event.currentTarget.value,
);
if (
next &&
!next.disabled &&
!period.disabled &&
next.value !== period.value
) {
period.onValueChange(next.value);
}
}}
value={selectedPeriod ? period.value : ""}
>
{!selectedPeriod ? (
<option disabled value="">
{period.label}
</option>
) : null}
{period.options.map((option) => (
<option
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
</div>
) : null}
<ul
className="m-0 grid list-none gap-4 p-0 sm:grid-cols-2 xl:grid-cols-3"
data-slot="pricing-plans-list"
role="list"
>
{plans.map((plan) => {
const planTitleId = `${id}-plan-${encodeURIComponent(plan.id)}`;
return (
<li className="min-w-0" key={plan.id}>
<Card
asChild
className={cn(
"flex h-full min-w-0 flex-col break-words",
plan.highlight && "border-primary ring-1 ring-primary",
)}
>
<article
aria-labelledby={planTitleId}
data-highlighted={plan.highlight ? "true" : undefined}
data-slot="pricing-plan"
>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<CardTitle id={planTitleId}>{plan.name}</CardTitle>
{plan.highlight ? (
<Badge variant="outline">{plan.highlight}</Badge>
) : null}
</div>
<CardDescription>{plan.description}</CardDescription>
<p className="pt-3">
<span className="text-3xl font-semibold">
{plan.price}
</span>{" "}
{plan.priceSuffix ? (
<span className="text-sm text-muted-foreground">
{plan.priceSuffix}
</span>
) : null}
</p>
{plan.billingNote ? (
<p className="text-sm text-muted-foreground">
{plan.billingNote}
</p>
) : null}
</CardHeader>
<CardContent className="flex-1">
{plan.features.length > 0 ? (
<ul className="list-disc space-y-2 pl-5 text-sm">
{plan.features.map((feature) => (
<li key={feature.id}>{feature.label}</li>
))}
</ul>
) : null}
</CardContent>
<CardFooter className="flex-col items-stretch gap-3">
{plan.action}
{plan.footnote ? (
<p className="text-sm text-muted-foreground">
{plan.footnote}
</p>
) : null}
</CardFooter>
</article>
</Card>
</li>
);
})}
</ul>
</>
)}
</section>
);
}
The documentation includes this file from canonical source at build time. Your application copy does not receive automatic updates. Follow the usage example below, connect your own behavior, and validate it in your application. Return to the Blocks catalog to choose another pattern.
Install by copying sourceLink to section
- Copy the complete
pricing-plansdirectory into an application-owned path. - Configure React, the public
@pycolors/uipackage, tokens and semantic styles through the UI installation guide. - Import the Block from your local entry point and supply your own offers.
- Run your application's lint, type-check, tests and build. Review offer terms, keyboard behavior, long content and both themes before using real offers.
"use client";
import { PricingPlans, type PricingPlan } from "./blocks/pricing-plans";
const samplePlans = [
{
id: "launch",
name: "Launch example",
description: "A fictional offer, not a PyColors product.",
price: "$12",
priceSuffix: "/ month",
billingNote: "Illustrative price only. No checkout is connected.",
features: [{ id: "projects", label: "One sample project" }],
action: <a href="/your-offer-details">Read offer details</a>,
},
] satisfies readonly PricingPlan[];
export function OfferComparison() {
return (
<PricingPlans
description="Replace the sample data and link with your own offer."
plans={samplePlans}
title="Compare offers"
/>
);
}The example link is an application-owned destination to replace, not a PyColors route or payment integration.
Consumer-owned propsLink to section
ComparisonLink to section
title is required and labels the section with an h2. description supplies
optional supporting text. plans is the ordered array of offers to display.
className customizes the root through the public cn utility.
With an empty plans array, the section displays emptyMessage, defaulting to
"No plans are available." It does not render an empty comparison grid or a
billing-period selector. An empty array is not treated as a loading or error
state; the application must decide what an absent result means.
OffersLink to section
Each PricingPlan supplies a stable id, name, description, formatted
price, features and action. Feature entries have their own stable id and
visible label.
priceSuffix can explain a period or unit. billingNote should clarify the
actual billing total and timing when they differ from a headline amount. The
Block renders both strings unchanged: it never multiplies, prorates, rounds,
formats currency or derives annual totals.
highlight supplies visible wording and an emphasized border. It does not
mean "most popular" or "recommended" unless the application explicitly supplies
that claim and can substantiate it. footnote adds consumer-owned context below
the action, such as eligibility or cancellation terms.
Keep offer IDs unique within the comparison and feature IDs unique within each offer. Use nonempty names, labels and period values. Separate instances receive independent heading and control IDs; reordering offers preserves their identity.
Optional controlled periodLink to section
Supply period with label, value, options and onValueChange. Options have
nonempty, unique value and label fields and may be disabled. The entire
control may also be disabled.
The native select requests a supported, enabled, different value. It does not
store a fallback selection or update offer data. The consuming application must
rerender both the selected value and the corresponding plans. The interactive
example above demonstrates this with explicit monthly and annual data.
No options means no selector. An invalid controlled value displays a disabled label placeholder instead of silently selecting the first period. Correct the value in the consumer; the Block does not invoke a callback during render.
For asynchronous offer data, coordinate disabled/loading presentation in the consumer so old prices are never presented as a newly selected period. This Block is not a data-fetching or request-race manager.
ActionsLink to section
action is a React node supplied by the consumer: a public PyColors Button,
native button, framework link or deliberately absent control. The Block does not
clone the element, change its destination, intercept its event, force a focus
transition or infer whether the visitor may buy.
Give each action a meaningful accessible name, and use type="button" for a
non-submitting button inside a surrounding form. Preserve disabled semantics.
Connect payments or permissions only through the consuming application's
validated server boundary; a disabled UI control is not authorization.
Responsive and accessibility behaviorLink to section
The comparison uses semantic section, heading, list and article markup. Offer
headings are h3 elements below the comparison h2; do not use this Block as a
page's only heading. Each article is associated with its own heading.
Cards stack on narrow screens, use two columns from sm, and three from xl.
Root and cards remain shrinkable, offer text wraps, and actions stay in a
consistent footer. Long content still requires visual review in your layout.
The period control is a labelled native select with native keyboard and disabled behavior, not a custom tab or radio implementation. Consumer controls keep their own focus and interaction semantics. No automatic focus movement, viewport JavaScript, animation or live-region pricing announcement is introduced.
Runtime tests cover semantics, controlled rerenders, callbacks, disabled states, independent instances and a representative axe scan. They do not constitute a visual contrast check or full accessibility certification.
Ownership, updates and exclusionsLink to section
Copying gives your application ownership of the source and its future changes. Compare later canonical revisions deliberately; no package version automatically updates your copy. Removing or reverting the copied directory rolls it back.
This Block does not include checkout, Stripe, subscriptions, taxes, currency conversion, discount arithmetic, purchase confirmation, authentication, authorization, entitlements, persistence, routing, a Registry or a new runtime dependency. The existing PyColors commercial offers are unchanged.
Use the Data table for record browsing or Settings panel for account forms. Need a broader application foundation? Compare Starters.