Pricing Tiers
One to four plans as cards, stacked rows or a wide banner; prices are facts the business sets.
npx shadcn@latest add @vibebox/pricing-tiersControls
<PricingTiers />About
Rebuilds the site kit's three-tier, two-tier and banner pricing as three variants of one block (the banner keeps every plan, so a swap loses nothing). pricing-price-flip (Pro) adds a period toggle.
Use it when
- The business publishes prices or starting prices.
Skip it when
- Prices are only ever quoted: use
booking-cta-bandwith a quote request.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/pricing-tiers - 3Use it:
import { PricingTiers } from "@/components/vibebox/pricing-tiers"; import { home } from "@/content/pages/home"; <PricingTiers content={home.pricing} variant="columns" id="pricing" />
Code
2 filescomponents/vibebox/pricing-tiers/index.tsx
/**
* VibeBox Motion · Pricing Tiers
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { ActionLink, Container, headingId, Icon, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { isFillIn } from "@/lib/vibebox/content/runtime";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* One to four plans, stated plainly: side-by-side cards, stacked rows, or one wide banner with
* the plans listed compactly. Prices are facts the business sets; until then they are fill-ins.
*/
type Plan = Content["plans"][number];
function Features({ plan, index, inline }: { plan: Plan; index: number; inline?: boolean }) {
return (
<ul className={cn(inline ? "flex flex-wrap gap-x-5 gap-y-1.5" : "grid gap-2.5")}>
{plan.features.map((feature, featureIndex) => (
<li key={featureIndex} className="flex min-w-0 items-start gap-2 text-small">
<Icon name="check" className="mt-0.5 size-4 text-foreground" />
<SlotText value={feature} path={`plans.${index}.features.${featureIndex}`} className="min-w-0 text-muted-foreground" />
</li>
))}
</ul>
);
}
function Price({ plan, index, size = "lg" }: { plan: Plan; index: number; size?: "lg" | "md" }) {
// A price nobody has set yet is a fill-in at reading size, not a display figure that crowds the card.
const display = !isFillIn(plan.price);
return (
<p className="m-0 flex min-w-0 flex-wrap items-baseline gap-x-1.5">
<SlotText
value={plan.price}
path={`plans.${index}.price`}
className={cn("min-w-0 max-w-full font-display font-semibold tracking-[-0.03em]", !display ? "text-[1.15rem]" : size === "lg" ? "text-[clamp(2rem,1.5rem+1.4vw,2.75rem)]" : "text-[1.6rem]")}
/>
<SlotText value={plan.period} path={`plans.${index}.period`} className="text-small text-muted-foreground" />
</p>
);
}
function Badge({ label, className }: { label?: string; className?: string }) {
return (
<span data-slot="recommendedLabel" className={cn("rounded-full bg-foreground px-2.5 py-0.5 text-caption font-semibold whitespace-nowrap text-background", className)}>
{label ?? "Recommended"}
</span>
);
}
export type PricingTiersProps = BlockProps<Content, Variant>;
export function PricingTiers({ content, variant = "columns", id, tone, className }: PricingTiersProps) {
const count = content.plans.length;
const note = <SlotText as="p" value={content.note} path="note" className="text-small text-pretty text-muted-foreground" />;
return (
<Section slug="pricing-tiers" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
<Container className="grid gap-[clamp(32px,4vw,56px)]">
{variant === "banner" ? (
<div className={cn("grid gap-10 rounded-xl p-6 ring-1 ring-border sm:p-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16 lg:p-14", REVEAL)} style={{ background: "linear-gradient(150deg, color-mix(in oklab, var(--spectrum-2) 12%, var(--card)), var(--card))" }}>
<div className="grid content-start gap-5">
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
{note}
</div>
<ul className="grid content-start divide-y divide-border">
{content.plans.map((plan, index) => (
<li key={index} className="grid gap-3 py-5 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<span className="flex flex-wrap items-center gap-2">
<SlotText as="h3" value={plan.name} path={`plans.${index}.name`} className="text-h3 font-semibold tracking-tight" />
{plan.recommended && <Badge label={content.recommendedLabel} />}
</span>
<Price plan={plan} index={index} size="md" />
</div>
<SlotText as="p" value={plan.description} path={`plans.${index}.description`} className="text-small text-muted-foreground" />
<Features plan={plan} index={index} inline />
<ActionLink action={plan.action} path={`plans.${index}.action`} size="sm" className="justify-self-start" />
</li>
))}
</ul>
</div>
) : (
<>
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} align={variant === "columns" ? "center" : "start"} />
{variant === "stacked" ? (
<ul className="grid gap-4">
{content.plans.map((plan, index) => (
<li
key={index}
className={cn(
"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-6 rounded-xl bg-card p-6 text-card-foreground ring-1 sm:p-8 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center",
plan.recommended ? "ring-2 ring-foreground" : "ring-border",
REVEAL,
)}
>
<div className="grid gap-2">
<span className="flex flex-wrap items-center gap-2">
<SlotText as="h3" value={plan.name} path={`plans.${index}.name`} className="text-h3 font-semibold tracking-tight" />
{plan.recommended && <Badge label={content.recommendedLabel} />}
</span>
<SlotText as="p" value={plan.description} path={`plans.${index}.description`} className="text-small text-muted-foreground" />
<Price plan={plan} index={index} size="md" />
</div>
<Features plan={plan} index={index} />
<ActionLink action={plan.action} path={`plans.${index}.action`} className="justify-self-start md:justify-self-end" />
</li>
))}
</ul>
) : (
<ul className={cn("mx-auto grid w-full gap-4", count === 1 ? "max-w-md" : count === 2 ? "max-w-4xl md:grid-cols-2" : count === 3 ? "md:grid-cols-3" : "sm:grid-cols-2 xl:grid-cols-4")}>
{content.plans.map((plan, index) => (
<li
key={index}
className={cn(
"relative grid min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-5 rounded-xl bg-card p-6 text-card-foreground ring-1 sm:p-7",
plan.recommended ? "ring-2 ring-foreground" : "ring-border",
REVEAL,
)}
>
{plan.recommended && <Badge label={content.recommendedLabel} className="absolute -top-3 left-6" />}
<div className="grid gap-2">
<SlotText as="h3" value={plan.name} path={`plans.${index}.name`} className="text-h3 font-semibold tracking-tight" />
<SlotText as="p" value={plan.description} path={`plans.${index}.description`} className="text-small text-muted-foreground" />
</div>
<Price plan={plan} index={index} />
<ActionLink action={plan.action} path={`plans.${index}.action`} className="w-full" />
<div className="border-t border-border pt-5">
<Features plan={plan} index={index} />
</div>
</li>
))}
</ul>
)}
<div className={cn(variant === "columns" && "text-center")}>{note}</div>
</>
)}
</Container>
</Section>
);
}components/vibebox/pricing-tiers/schema.ts
/**
* VibeBox Motion · Pricing Tiers
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { sections, type z } from "@/lib/vibebox/content";
export const variants = ["columns", "stacked", "banner"] as const;
export type Variant = (typeof variants)[number];
export const content = sections.pricing;
export type Content = z.infer<typeof content>;Content
Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| lead | Intro | text | 160 chars | — |
| plans | Plans | list | up to 4 | — |
| plans[].name | Plan | text | 32 chars | — |
| plans[].price | Price | price | 32 chars | Yes, a fill-in until given |
| plans[].period | Period | text | 16 chars | — |
| plans[].description | Description | text | 140 chars | — |
| plans[].features | Included | list | up to 8 | — |
| plans[].features[] | Included | text | 60 chars | Can be a fill-in |
| plans[].action | Button | action | 24 chars | — |
| plans[].recommended | Recommended | flag | — | — |
| recommendedLabel | Recommended badge | text | 20 chars | — |
| note | Note | text | 160 chars | — |
Variants
Columnsdefault
columns
Plan cards side by side, centred heading.
Stacked
stacked
One row per plan: name and price, inclusions, action.
Banner
banner
A wide gradient panel: heading and note left, compact plan list right.
Accessibility and motion
- Keyboard
- Each plan's action, in order.
- Screen readers
- Each plan is a list item with a heading, its price, its description, its inclusions as a list and its action.
- Touch
- Nothing depends on hover.
- Reduced motion
- Plans appear without their reveal.
Edge cases and limits
- Four plans with eight inclusions each stay readable at 1440 px.
- Prices are free text, so "From ₱900 a month" works.
Related

Plans with two periods: a switch flips every price on its axis to monthly or yearly, per session or pack.
Pricing block · CSS

About Story
FreeThe business in its own words: a tall photo with a caption beside the story and a few honest details.
About block · Server

Bento Hero
FreeThe promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.
Heroes block · Server