Skip to content
Motion
FreeIn reviewv1.0.0

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-tiers

Controls

Variant
<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-band with a quote request.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/pricing-tiers
  3. 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 files
components/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.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
plansPlanslistup to 4—
plans[].namePlantext32 chars—
plans[].pricePriceprice32 charsYes, a fill-in until given
plans[].periodPeriodtext16 chars—
plans[].descriptionDescriptiontext140 chars—
plans[].featuresIncludedlistup to 8—
plans[].features[]Includedtext60 charsCan be a fill-in
plans[].actionButtonaction24 chars—
plans[].recommendedRecommendedflag——
recommendedLabelRecommended badgetext20 chars—
noteNotetext160 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.
  • Three plan cards with a monthly and yearly switch above them

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

    Pricing block · CSS

  • A tall horizon scene beside the story of a family dental clinic and three details

    The business in its own words: a tall photo with a caption beside the story and a few honest details.

    About block · Server

  • A headline about moving better beside a mosaic of a tile scene, a bloom scene and a class list

    The promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.

    Heroes block · Server

↑ ↓ to move↵ to openesc to close