Guides · September 4, 2023
Pricing Tables & Bundle Offers with shadcn
Building a shadcn pricing table that actually converts means designing the plan comparison, the highlighted tier, and bundle/discount logic as one system, not three disconnected components. Here is the full pattern, end to end.
By Polo Themes
A shadcn pricing table is really three components working together: a Card-based plan grid, a Badge-driven highlight for the recommended tier, and a Tabs or Switch toggle for monthly/annual or single/bundle pricing. Get the underlying data model right first — plans as typed objects, not hardcoded JSX — and the visual polish (the popular-plan border glow, the strikethrough bundle discount, the feature checklist) becomes straightforward composition on top of shadcn's existing primitives. This guide walks through the full build, from data shape to bundle-offer logic to accessibility and copy details that most tutorials skip.
Why Pricing Tables Are Harder Than They Look
Pricing pages get disproportionate scrutiny relative to how simple they look. A visitor lands there already comparing you against two or three competitors in other tabs, so the page has one job: make the comparison effortless and make the intended choice obvious without feeling manipulative. That means the engineering problem is not "render three cards" — it is "encode a decision" in markup. Every visual decision (which tier gets the colored border, whether the toggle defaults to annual, how the bundle discount is phrased) is a product decision wearing a CSS class, and if the underlying data model is sloppy, the UI will fight you every time you add a plan, run a promotion, or A/B test a headline.
shadcn/ui is a good fit for this precisely because it is not a component library in the traditional sense — it is a set of composable, copy-into-your-repo primitives (Card, Badge, Button, Tabs, Separator, Switch) built on Radix and Tailwind. You are not fighting a black-box "PricingTable" component with seventeen undocumented props; you own the source, so the toggle logic, the featured-tier styling, and the bundle math all live in code you can read and change. That ownership matters more for pricing tables than almost any other UI pattern, because pricing logic changes constantly and a component you cannot see into becomes a liability the first time marketing asks for a "launch week" banner.
Step 1: Model Plans as Data, Not Markup
Before touching a single Card component, define the shape of a plan. This is the single highest-leverage decision in the whole build, because every later feature — annual discounts, bundle pricing, feature comparisons, currency switching — is just a transformation over this data.
- id / slug: a stable key for the plan, used for analytics events and URL state (so a linked "annual" view is shareable).
- name and tagline: short plan name plus a one-line description of who it is for.
- priceMonthly and priceAnnual: store both as numbers (in the smallest currency unit if you are precise about it) rather than pre-formatted strings — formatting is a render-time concern, not a data concern.
- highlighted: a boolean or a rank, not a hardcoded "this is the middle card" assumption — plans get reordered more often than you expect.
- features: an array of { label, included } or { label, value } pairs so a comparison table and a checklist can both read from the same source.
- bundleOf: an optional array of other plan/product ids this tier bundles, used to compute and display the discount versus buying separately.
Once plans are data, the component becomes a pure function: given an array of plan objects and a billing interval, render N cards. That single change eliminates most of the copy-paste bugs that show up in pricing tables built card-by-card — a feature line that is bolded on one card and not another, a price that says "/mo" on two cards and "/month" on the third, a highlighted border that someone forgot to remove from last quarter's featured plan.
Step 2: The Card Layout
shadcn's Card primitive (CardHeader, CardTitle, CardDescription, CardContent, CardFooter) maps almost one-to-one onto a pricing card's natural sections: header for the plan name and price, content for the feature list, footer for the call-to-action button. Resist the urge to reach for a custom div structure — the semantic sections keep spacing consistent across cards automatically, because they share the same padding and typography tokens rather than each card reinventing its own.
For the featured or "most popular" tier, the common pattern is a combination of three small things: a slightly elevated shadow or a colored border (using your accent token, not a hardcoded hex), a Badge positioned absolutely at the top of the card reading "Most Popular" or "Best Value," and a small scale transform on desktop, such as a 105% scale, that makes the card read as physically larger without breaking the grid. Keep the scale subtle — anything past about 5% starts to look like a layout bug on wide monitors rather than an intentional design choice. On mobile, drop the scale entirely and rely on the badge and border alone; a scaled card in a single-column mobile layout just wastes vertical space.
Feature list rendering
Render the feature array as a list with a consistent icon-and-label pattern — a check icon (lucide-react's Check pairs naturally with shadcn) for included features, and either an omitted row or a muted-color X for excluded ones, depending on whether you want tiers to show a full comparison or just their own included features. Full comparison (every tier lists every feature, included or not) reads as more transparent and is generally the better default for B2B or dev-tool pricing, where buyers actively compare tiers line by line. Own-features-only (each card lists just what it includes) is punchier and works better for simpler consumer products with two or three tiers, where the difference is more about scope than a long feature matrix.
Step 3: The Monthly/Annual Toggle
Use shadcn's Tabs component when you want the toggle to also serve as a visible label ("Monthly" / "Annual, save 20%"), or a Switch plus a text label when you want a more minimal single-control toggle. Tabs has a slight edge for pricing pages specifically, because the discount messaging can live directly on the "Annual" tab trigger as a small Badge, which keeps the incentive visible even before the user interacts with the control — a bare Switch has to earn that same visibility through separate copy next to it.
Whichever control you choose, drive it from a single piece of lifted state, such as a "monthly or annual" value held in the parent component, that every card reads from, rather than each card managing its own toggle. This is a common bug in hand-rolled pricing tables: the toggle looks like one control but is actually wired per-card, so switching intervals updates the price on the card you clicked and leaves the others stale until a re-render happens to fire. A single lifted state value, passed down as a prop, makes the whole table update atomically.
For the price display itself, animate the digit change rather than just swapping text — even a simple CSS transition on a key-changed span reduces the jarring "the whole card just reset" feeling. If you want to go further, a monospaced tabular-nums font for the price digits keeps the layout from jittering as digit counts change (e.g. $9 to $90 when switching from monthly to an annual total).
Step 4: Bundle Offers — the Part Most Tutorials Skip
A bundle offer is different from a discount toggle: it is not "the same product, cheaper if you commit longer," it is "multiple distinct products, cheaper together than separately." That distinction matters for the UI, because a bundle needs to visibly show what is being combined and what the saving actually is, or the offer reads as an arbitrary price rather than a deal.
The pattern that works best in practice is a dedicated bundle card, visually distinct from the individual-plan cards (often placed at the end of the row, or above it as a full-width banner card), with three pieces of information rendered explicitly: the components of the bundle (as a short list or set of small icons/logos), the combined price, and the savings — shown as both a struck-through "buy separately" total and a computed percentage or flat-amount saved. Compute that savings number in code from the underlying plan data (separate-price sum minus bundle price), never hardcode a percentage string, because a hardcoded "save 30%" silently goes stale the moment either component's price changes.
- Show the strikethrough original total next to the bundle price so the saving is visually obvious, not just stated in text.
- Label what is actually included — "Includes: Theme + Figma Kit," not a vague "Bundle" tag with no contents shown.
- If the bundle has a real availability constraint (a matched theme-plus-kit pairing, a time-limited launch bundle), say so plainly rather than implying every combination is bundleable.
- Keep the bundle's call-to-action copy distinct from the individual plans' — "Get the Bundle" reads more intentional than a repeated generic "Buy Now" across every card.
This is a place where honesty compounds into trust: a bundle offer that overstates its savings, or bundles a "discount" against an inflated list price, is usually detectable by a buyer within about ten seconds of comparing tabs, and it costs you more in credibility than the extra few percent of margin gains you. If you are building bundle offers for your own store — a matched design-plus-theme pairing is the most natural example — do the arithmetic honestly and let the real saving speak for itself.
Step 5: Accessibility and Copy Details
A pricing table gets more scrutiny from screen reader users than most pages realize, because the visual layout (cards side by side, a highlighted middle tier) does not translate automatically into a sensible reading order. A few concrete fixes matter here. Use a real heading (h3, since the page's h1/h2 belong to the surrounding page) for each plan name, so the table is navigable by heading. Do not rely on color or border alone to convey the "most popular" designation — the Badge text itself needs to say it, since a screen reader will not announce a colored border. And make sure the toggle's state change is announced: a live region, or a properly labeled Radix Tabs/Switch (which shadcn's primitives already wire up correctly by default), will announce "Annual" or "Monthly" when the control changes, so a screen reader user is not left guessing whether the prices below just changed.
On copy: avoid vague feature labels like "Priority support" or "Advanced features" repeated near-verbatim across tiers with only an included/excluded checkmark differentiating them — if a buyer cannot tell what actually changes between two tiers from the feature list alone, they will assume the higher tier is not worth it and default to the cheapest option. Every feature row should describe something concretely different, ideally with a number or scope attached ("Support: Email, 48h response" vs. "Support: Email + chat, 4h response") rather than an adjective doing all the work.
Putting It Together
The full component tree, top to bottom, looks like: a billing-interval Tabs or Switch control driving a single piece of lifted state; a responsive grid (grid-cols-1 on mobile, up to grid-cols-3 or grid-cols-4 on desktop) of Card components mapped from your plan data array; a highlighted card variant applying a border/shadow/Badge combination based on the plan's own highlighted flag rather than its position in the array; and, where relevant, a distinct bundle card computing its savings live from the same plan data rather than a separately maintained number. None of this requires exotic components — it is disciplined composition of Card, Badge, Tabs, Switch, and Separator, plus a data model that treats pricing as structured information rather than as strings baked into JSX.
If you are designing this in Figma before you build it, working from a component library that already separates plan-card structure, badge variants, and toggle states as reusable pieces saves a real amount of back-and-forth between design and implementation — our Figma UI kits are built with that kind of component discipline in mind, so a pricing-table pattern designed there maps cleanly onto the shadcn structure described above rather than needing to be re-decomposed during handoff.
Frequently Asked Questions
Should the pricing table default to monthly or annual billing?
Default to whichever interval most of your traffic actually converts on, and if you do not have that data yet, monthly is the safer default — it shows the lower, less commitment-heavy number first and lets the annual discount act as an upsell the user opts into, rather than an inflated number they see first and have to mentally discount.
How many pricing tiers should a shadcn pricing table show?
Three is the most common and usually the easiest to design around, since it naturally supports a "low, recommended, high" structure with one clearly highlighted middle tier. Four tiers can work for products with a genuine enterprise/custom tier, but avoid going higher than that on a single row — beyond four, comparison gets cognitively harder and conversion tends to drop rather than rise.
Does shadcn have a built-in pricing table component?
No — shadcn/ui deliberately ships primitives (Card, Badge, Tabs, Switch, Button) rather than a packaged "PricingTable" component, which is why the data-model-first approach in this guide matters: you are composing the pattern from primitives you own, not configuring a prebuilt block.
How should a bundle discount be calculated and displayed?
Compute it in code as the sum of the bundled items' individual prices minus the bundle price, then display both the struck-through original total and the resulting saving (as a percentage or flat amount). Never hardcode the saved percentage as a separate string — it will drift out of sync the moment any component price changes.