PVR Tech Studio

Pricing

A polished marketing pricing landing page — hero, logo marquee, plan cards, sticky comparison table, impact band, testimonial, FAQ, and CTA.

8 min read
Updated July 15, 2026

Overview

The Pricing page is a full marketing landing page rendered inside the app. It reads like a real SaaS pricing page, not a dashboard screen: it is full-bleed, forces header-only presentation (no app sidebar), and hides the chat widgets — so the page owns the full viewport width. The editorial flow is:

hero (rotating headline + ambient canvas) → customer-logo marqueeplans (monthly/annual toggle, 4 tiers) → sticky comparison tableimpact-metrics bandtestimonialFAQCTA, over a page-wide dot-grid texture, with animated wave dividers between full-width bands.

It is built from focused section components in src/components/pricing/, driven by data tables in src/data/pricing.ts. All copy is i18n (the pricing namespace, plus nav for the tab title); every color is a semantic token (skin- and dark-safe); and all page text is pinned to Plus Jakarta Sans via a single inline --font-* override on the page root. It leans on the free motion/react FX components (DotGrid, WaveDivider, HeroCanvas, RotatingWord, Reveal, Parallax, Stagger, AnimatedNumber, FadeIn) — see Animation & Effects.

Architecture & files

FileResponsibility
src/pages/apps/PricingPage.tsxComposes the sections in editorial order over a page-wide DotGrid; pins all text to Plus Jakarta Sans; sets the tab title.
src/data/pricing.tsSingle source of truth: TIERS, COMPARE, FAQS, LOGOS, HERO_STATS, IMPACT_STATS, the BillingCycle type, ANNUAL_DISCOUNT, and JAKARTA_FONT_STACK. Stores i18n keys; prices/numbers stay literal.
src/components/pricing/PricingHero.tsxHero section: badge, rotating headline word, subtitle, CTAs, trust-stat strip over HeroCanvas, wave divider.
src/components/pricing/TrustedBy.tsxCustomer-logo band (wraps LogoMarquee).
src/components/pricing/PricingPlans.tsxPlans section: heading, the monthly/annual billing Tabs toggle (owns the cycle state), the "Save 20%" incentive, and the staggered PricingCard grid.
src/components/pricing/PricingCompare.tsxComparison section (wraps CompareTable).
src/components/pricing/ImpactBand.tsx"Why teams switch" impact-metrics band (wraps StatGrid variant="impact").
src/components/pricing/PricingTestimonial.tsxTestimonial / social-proof block.
src/components/pricing/PricingFaq.tsxFAQ accordion (from FAQS).
src/components/pricing/PricingCta.tsxClosing call-to-action band.
src/components/pricing/SectionHead.tsxReusable editorial section header (eyebrow + display title + subtitle, center/left).
src/components/pricing/PricingCard.tsxOne tier card: free / paid / contact-sales, annual-savings anchoring, and the elevated "Most popular" treatment.
src/components/pricing/CompareTable.tsxThe feature-comparison table with sticky tier headers on scroll and a repeated CTA footer row.
src/components/pricing/StatGrid.tsxCount-up stat row/grid shared by the hero strip (hero) and impact band (impact).
src/components/pricing/LogoMarquee.tsxRenders LOGOS as real logo lockups (lucide mark + wordmark).
src/components/pricing/index.tsBarrel re-exporting the reusable components + page sections.

Data model

export type BillingCycle = 'monthly' | 'annual'
 
export interface PricingTier {
    id: string
    nameKey: string
    taglineKey: string
    monthly: number // per-month price billed monthly
    annual: number // per-month price billed annually (already discounted)
    ctaKey: string
    ctaVariant: 'primary' | 'outline'
    featureKeys: string[] // i18n keys for the bullet list
    popular?: boolean // elevated "Most popular" treatment
    contactSales?: boolean // show "Contact sales" instead of a price
    isFree?: boolean // show "$0 / Free forever"
}

The seed ships four tiers — Free, Pro (popular), Business, and Enterprise (contactSales). Annual figures are the discounted per-month equivalent so the card shows one "/mo" value for either cycle; ANNUAL_DISCOUNT drives the "Save 20%" copy (note: the per-tier annual prices are authored independently, so keep them in sync if you change the discount).

COMPARE is an array of CompareGroups (Core / Collaboration / Support), each with CompareRows whose values are indexed to TIERS order — a boolean renders a check/dash, a string renders the value (a cell* string is itself an i18n key). FAQS, LOGOS, HERO_STATS, and IMPACT_STATS are similarly plain data arrays.

Presentation (route flags)

// src/routes.tsx
{path: '/apps/pricing', element: <PricingPage />, fullBleed: true, headerOnly: true, hideWidgets: true},

These are per-route presentation flags (see Layout System) — they force the marketing-page look without mutating the user's saved layout config: fullBleed drops the <main> padding, headerOnly hides the app sidebar, and hideWidgets suppresses the chat rail/bubble.

Usage

Navigate to /apps/pricing. The page is self-contained:

import {PricingPage} from '@/pages/apps/PricingPage'
 
;<PricingPage />

The reusable pieces can be dropped into any page via the barrel:

import {SectionHead, PricingCard, CompareTable, StatGrid} from '@/components/pricing'
import {TIERS, HERO_STATS} from '@/data/pricing'

API / Props

The sections (PricingHero, TrustedBy, PricingPlans, …) take no props — they read from src/data/pricing.ts and translate their own keys. The reusable building blocks are parameterized:

SectionHead

PropTypeDescription
eyebrowstringSmall uppercase label with a leading rule.
titlestringDisplay heading.
subtitle?stringOptional supporting line.
align?'center' | 'left'Default 'center'.
className?stringExtra classes.

PricingCard

PropTypeDescription
tierPricingTierThe tier data.
cycleBillingCycle'monthly' or 'annual' — selects which price to show (with strikethrough anchor + animated count-up on change).

Handles the free / paid / contact-sales branches, the "Most popular" strip + ring + lift, the tier icon (mapped by tier.id), and the feature checklist.

StatGrid

PropTypeDescription
statsStat[]{value, prefix?, suffix?, decimals?, labelKey} entries.
variant'hero' | 'impact'hero = compact inline row (foreground); impact = large emphasised grid (primary).

CompareTable / LogoMarquee

Take no props — they render COMPARE + TIERS and LOGOS respectively. CompareTable sticks the tier-name header row under the app header on scroll (offset by --page-header-sticky-top) and repeats the plan CTAs in a footer row.

Configuration & customization

Edit / add / remove a plan

Everything is data-driven from TIERS in src/data/pricing.ts:

export const TIERS: PricingTier[] = [
    {
        id: 'pro',
        nameKey: 'proName',
        taglineKey: 'proTagline',
        monthly: 29,
        annual: 23, // discounted per-month equivalent
        ctaKey: 'ctaStart',
        ctaVariant: 'primary',
        popular: true,
        featureKeys: ['proF1', 'proF2', 'proF3', 'proF4', 'proF5'],
    },
    // add / remove tiers here…
]

When you add a tier: (1) add the entry, (2) add its nameKey / taglineKey / featureKeys / ctaKey labels to src/locales/<lng>/pricing.json, (3) add a value to every CompareRow.values array in COMPARE (values are indexed to TIERS order), and (4) optionally map a tier icon by id in TIER_ICONS inside PricingCard.tsx. The plan grid, comparison table columns, and the comparison footer CTAs all derive from TIERS.

Comparison rows

Add a CompareRow under the relevant CompareGroup. Use true/false for check/dash, a plain string for a literal cell, or a cell* string (itself an i18n key) for a translated cell. hintKey adds a tooltip next to the feature name.

FAQ, logos, stats

Edit the FAQS, LOGOS, HERO_STATS, and IMPACT_STATS arrays in src/data/pricing.ts. LOGOS icon values are lucide icon keys resolved to components in LogoMarquee.tsx.

Fonts & colors

All page text is pinned to Plus Jakarta Sans via a single inline --font-* override on the page root (jakartaVars in PricingPage.tsx, using JAKARTA_FONT_STACK) — this beats the skin / html[lang] font rules for the page subtree. Colors are semantic tokens only, so the page adapts to every skin and dark mode without edits.

Examples

Reuse a pricing card grid elsewhere

import {useState} from 'react'
import {PricingCard} from '@/components/pricing'
import {TIERS, type BillingCycle} from '@/data/pricing'
 
function MiniPricing() {
    const [cycle, setCycle] = useState<BillingCycle>('monthly')
    return (
        <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
            {TIERS.map((tier) => (
                <PricingCard key={tier.id} tier={tier} cycle={cycle} />
            ))}
        </div>
    )
}

An editorial section header + impact stats

import {SectionHead, StatGrid} from '@/components/pricing'
import {IMPACT_STATS} from '@/data/pricing'
 
;<>
    <SectionHead eyebrow="Impact" title="Why teams switch" />
    <StatGrid stats={IMPACT_STATS} variant="impact" />
</>

Best practices

  • Keep TIERS and COMPARE in sync. Comparison values are positional — one entry per tier, in TIERS order. Adding a tier without extending every row shifts the columns.
  • Store copy as keys. Names, taglines, features, FAQs, CTA labels are i18n keys; only prices and raw numbers stay literal.
  • Tokens for color, motion.ts for motion. Never hardcode hex; reuse the FX wrappers rather than raw motion.*.
  • Prices in one place. The annual price is authored per tier (not derived) — if you change ANNUAL_DISCOUNT, update each annual value and the "Save 20%" copy together.
  • Don't reach for Motion+. The hero canvas, rotating word, dot-grid, and wave dividers are all free-core reimplementations — keep it that way (see the no-Motion+ rule in CLAUDE.md).

Troubleshooting

SymptomCause / fix
Comparison columns look shiftedA CompareRow.values array has the wrong length — it must have exactly one value per tier, in TIERS order.
Sticky tier headers hide behind the app headerCompareTable offsets the sticky row by --page-header-sticky-top; keep that offset if you restyle it.
Rows bleed through the "Most popular" sticky cellThe popular header uses an opaque color-mix tint (not a translucent /[0.06]) precisely so scrolling rows don't show through — don't switch it to an alpha class.
The sidebar / chat widgets appear on the pageThe marketing look depends on the route flags headerOnly + hideWidgets in src/routes.tsx; keep them on the route entry.
Fonts don't match the rest of the appIntentional — the page pins Plus Jakarta Sans via jakartaVars. Remove that override to inherit the skin font.
Annual price wrong / discount mismatchedannual is authored independently of ANNUAL_DISCOUNT; edit the per-tier value.

FAQ

Does the checkout / CTA do anything? No — the CTAs are presentational (marketing demo). Wire them to your billing flow or the api client as needed.

Is billing state global? No — the monthly/annual cycle lives only in PricingPlans (it's used just there). It is not persisted.

Can I use the sections outside this page? Yes — the reusable pieces (SectionHead, PricingCard, CompareTable, StatGrid, LogoMarquee) are exported from the barrel and take plain props/data.

Why is it under /apps? It's demoed as an in-app landing page (a common ThemeForest showcase), using the per-route presentation flags to shed the dashboard chrome.

Notes for designers & content editors

  • All copy — plan names, taglines, feature bullets, comparison labels, FAQs, testimonial, CTA — is i18n keys in src/locales/<lng>/pricing.json (the tab title comes from nav). Prices and numbers are literal in src/data/pricing.ts.
  • Colors are semantic tokens; the whole page re-skins and dark-modes automatically. The "Most popular" accent follows --primary.
  • Motion is reduced-motion safe — the canvas, rotating word, parallax, and count-ups all respect the global reduced-motion setting.
  • Logos are icon + wordmark lockups (not images), so they inherit token colors and stay crisp.
  • Fonts are pinned to Plus Jakarta Sans for a consistent marketing voice, independent of the active skin/language font.

Was this page helpful?