Fact Vite-based TypeScript templates typically produce faster hot module replacement (HMR) speeds compared to CRA — helpful for front-end component development.
ieldset>
Warning Don't commit node_modules. Use .gitignore and consider lockfile hygiene (package-lock.json or yarn.lock) to avoid dependency drift across environments.
Further Reading
Before writing UI, decide the component API. Clear TypeScript interfaces make the component predictable and easier to reuse.
Top-level components and responsibilities
PricingTable: accepts an array of packages and optional props that control layout and global behavior (e.g., billing toggle, currency selection).
PricingCard: presentational unit that renders a single package; accepts package data and callbacks for actions like onSelect or onCtaClick.
Example TypeScript interfaces (suggested)
export interface Discount {
type: 'percent' | 'amount'
value: number
label?: string
}
export interface PricingPackage {
id: string
name: string
price: number
currency?: string
interval?: 'month' | 'year' | string
features: string[]
ctaText?: string
badge?: string
isFeatured?: boolean
highlightColor?: string
discount?: Discount
metadata?: Record<string, unknown>
}
export interface PricingCardProps {
pkg: PricingPackage
onCtaClick?: (pkgId: string) => void
className?: string // for styled-components
}
export interface PricingTableProps {
packages: PricingPackage[]
layout?: 'grid' | 'stack'
showToggle?: boolean
defaultInterval?: 'month' | 'year'
onPackageSelect?: (pkgId: string) => void
}
Which props drive behavior vs. presentation
Behavioral props: onCtaClick, onPackageSelect, showToggle — these change how the component behaves and integrate it with application logic.
Presentation props: layout, defaultInterval — these change visual layout and initial state but not business behavior.
Default props and prop validation
Use sensible defaults: layout = 'grid', defaultInterval = 'month', showToggle = false.
Because TypeScript is used, runtime prop validation via PropTypes is optional but can still be useful if data comes from untyped sources (CMS or third-party APIs).
Accessibility and event contracts
Expose callbacks with clear semantics (e.g., onPackageSelect receives the package id).
Ensure PricingCard provides appropriate aria attributes (aria-pressed for selected state, aria-label for price areas). We'll implement these in Part 2.
Testing and documentation tips
Document the interfaces using inline comments and a README. Small, well-documented components are much easier to reuse.
Add unit tests for formatting and behavior (e.g., price formatting, rendering of badges, CTA click events).
Tip Design with immutability in mind: components should not mutate package objects. Treat package data as read-only props and emit events for state changes.
Fact Explicit TypeScript interfaces make it easy for other developers to import types and build UIs around the pricing table without reading implementation details.
Warning Avoid passing large, nested objects directly to children if only a few fields are used. Consider projecting (selecting) the needed fields to keep render props small and clear.
Further Reading
The PricingCard is the visual building block of the pricing table: a focused, reusable card that receives a package object and renders title, price, features, and a CTA. Keep the card small and presentational — accept data and callbacks via props, avoid internal business logic, and expose clearly named props for variants (e.g., highlighted, label, onSelect).
Why styled-components here: it keeps styles colocated with the component, supports theming tokens, and makes variant styling straightforward using props.
Example component (TypeScript-flavored pseudocode):
// PricingCard.tsx
import React from 'react'
import styled, { css } from 'styled-components'
type Feature = { id: string; text: string }
type Package = {
id: string
title: string
tagline?: string
prices: { monthly: number; yearly: number }
currency?: string
features: Feature[]
ctaLabel?: string
}
type Props = {
pkg: Package
frequency: 'monthly' | 'yearly'
highlighted?: boolean
onSelect?: (pkgId: string) => void
}
const Card = styled.article<{ highlighted?: boolean }> background: ${({ theme }) => theme.colors.cardBg}; border-radius: 8px; padding: 1.25rem; box-shadow: ${({ highlighted, theme }) => highlighted ? theme.shadows.pop : theme.shadows.base}; border: 1px solid ${({ highlighted, theme }) => highlighted ? theme.colors.accent : theme.colors.border}; display: flex; flex-direction: column; transition: transform 160ms ease, box-shadow 160ms ease; ${({ highlighted }) => highlighted && csstransform: translateY(-4px);}
const Title = styled.h3 margin: 0 0 .5rem 0; color: ${({ theme }) => theme.colors.textTitle}; font-size: 1.125rem;
const Price = styled.p margin: 0 0 1rem 0; font-size: 1.5rem; font-weight: 700;
const FeatureList = styled.ul list-style: none; padding: 0; margin: 0 0 1rem 0; display: grid; gap: .5rem;
const CTA = styled.button margin-top: auto; background: ${({ theme }) => theme.colors.ctaBg}; color: ${({ theme }) => theme.colors.ctaText}; border: none; padding: .75rem 1rem; border-radius: 6px; cursor: pointer;
export const PricingCard: React.FC<Props> = ({ pkg, frequency, highlighted, onSelect }) => {
const displayPrice = frequency === 'yearly' ? pkg.prices.yearly : pkg.prices.monthly
return (
<Card highlighted={highlighted} aria-label={${pkg.title} plan}>
<header>
<Title>{pkg.title}</Title>
{pkg.tagline && <p aria-hidden>{pkg.tagline}</p>}
</header>
<Price aria-live="polite">{pkg.currency || '$'}{displayPrice}{frequency === 'yearly' ? '/yr' : '/mo'}</Price>
<FeatureList role="list" aria-label={`${pkg.title} features`}>
{pkg.features.map(f => (
<li key={f.id}>{f.text}</li>
))}
</FeatureList>
<CTA
onClick={() => onSelect?.(pkg.id)}
aria-label={`Choose ${pkg.title} plan`}
>
{pkg.ctaLabel || 'Get started'}
</CTA>
</Card>
)
}
Key implementation notes
Semantic HTML: use for each card, an explicit for the title, and a list () for features. This helps screen readers understand the structure.
ARIA: set aria-live on the price if it can change (e.g., when toggling frequency). Use explicit aria-label on the CTA if the visible text alone might be ambiguous.
Variant API: expose a simple boolean highlighted prop. This keeps the component API stable while letting consumers mark popular plans without introducing many flags.
Separation of concerns: keep data mapping in the component (title -> , features -> ), but avoid embedding price calculation logic — accept computed prices or a frequency prop as above.
Tip
Use plain elements for CTAs rather than clickable s — native buttons provide keyboard and accessibility behavior out of the box.
Fact
Providing a `frequency` prop instead of passing a resolved price lets the card display prices in either context (monthly/yearly) without being coupled to a billing calculation.
Further Reading
The PricingTable composes multiple PricingCard instances. The table should accept an array of package data and render a responsive grid. Make the layout configurable via props such as columns, gap, and align while providing sensible defaults.
Basic component signature:
type PricingTableProps = {
packages: Package[]
columns?: number | { desktop: number; tablet?: number; mobile?: number }
gap?: string
align?: 'stretch' | 'center' | 'start'
frequency?: 'monthly' | 'yearly'
onSelect?: (pkgId: string) => void
}
Grid implementation (styled-components):
const Grid = styled.section<{ columns: number; gap: string; align: string }>`
display: grid;
gap: ${({ gap }) => gap};
grid-template-columns: repeat(${({ columns }) => columns}, 1fr);
align-items: ${({ align }) => align};
/* Responsive example using theme breakpoints */
@media (max-width: ${({ theme }) => theme.breakpoints.tablet}) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: ${({ theme }) => theme.breakpoints.mobile}) {
grid-template-columns: 1fr;
}
`
Rendering with keys:
export const PricingTable: React.FC<PricingTableProps> = ({ packages, columns = 3, gap = '1rem', align = 'stretch', frequency = 'monthly', onSelect }) => {
return (
<Grid columns={typeof columns === 'number' ? columns : columns.desktop} gap={gap} align={align} role="list">
{packages.map(pkg => (
<div key={pkg.id} role="listitem">
<PricingCard pkg={pkg} frequency={frequency} highlighted={pkg.id === 'popular'} onSelect={onSelect} />
</div>
))}
</Grid>
)
}
Important considerations
Keys: always use a stable unique key (package.id). Avoid array indices as keys except for static lists.
Layout props: accept either a single number or an object to allow different column counts at different breakpoints. Convert the prop into concrete grid-template-columns in CSS.
Accessibility: wrapping each card in an element with role="listitem" helps when the grid is a logical list of plans. Use role="list" on the grid container.
Stacking behavior: define breakpoints in your theme and choose when to go from multi-column to stacked single-column. A common pattern: desktop 3 columns, tablet 2 columns, mobile 1 column.
Tip
Map data to components with Array.map and pass a stable `key` prop. Keep mapping logic declarative and simple — no side effects in render.
Further Reading
Responsiveness and theming are best handled in tandem via tokens. Define breakpoints and design tokens in a theme object and consume them inside styled-components so layout and visuals adapt without changing markup.
Sample theme tokens:
const theme = {
colors: {
background: '#fff',
cardBg: '#ffffff',
border: '#e6e8eb',
accent: '#0b76ef',
textTitle: '#0f172a',
ctaBg: '#0b76ef',
ctaText: '#fff'
},
breakpoints: {
desktop: '1200px',
tablet: '900px',
mobile: '600px'
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px'
},
typography: {
scale: {
body: '1rem',
lead: '1.125rem',
title: '1.25rem'
}
},
shadows: {
base: '0 1px 4px rgba(15, 23, 42, 0.06)',
pop: '0 6px 20px rgba(11, 118, 239, 0.12)'
}
}
Usage with ThemeProvider:
import { ThemeProvider } from 'styled-components'
ReactDOM.render(
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>,
root
)
Responsive typography and spacing
Use relative units (rem) for font sizes and spacing so the layout scales with root font-size.
For fluid typography between breakpoints, you can use clamp() or calc() in CSS to interpolate sizes.
On smaller screens, increase vertical rhythm: stack cards and increase the CTA width to full width for easier tapping.
Customizing appearance
Provide theme overrides for colors, spacing, and radii. Consumers can wrap the PricingTable with a custom ThemeProvider to restyle without changing component code.
Expose a small set of style props for quick adjustments: compact?: boolean or accentColor?: string — but prefer themes for broad customizations.
Fact
A theme-driven approach keeps components agnostic to color and spacing systems and makes it much easier to support light/dark modes.
Further Reading
Interactivity is often required for pricing UIs: a monthly/yearly toggle, plan selection state, and CTA events. Decide where state lives based on who needs it:
Local state: when only the card needs the state (e.g., collapsed feature list), keep it inside the component.
Lifted state (recommended for frequency toggle): place the frequency state in the PricingTable (parent) so all cards update consistently.
Global/context: if the selected plan or frequency must be shared across many parts of the app (checkout, header), use Context or your app store.
Example: frequency toggle in the parent
// PricingTableWrapper.tsx
export const PricingTableWrapper: React.FC<{ packages: Package[] }> = ({ packages }) => {
const [frequency, setFrequency] = React.useState<'monthly'|'yearly'>('monthly')
const [selected, setSelected] = React.useState<string | null>(null)
return (
<div>
<div role="tablist" aria-label="Billing frequency">
<button aria-pressed={frequency === 'monthly'} onClick={() => setFrequency('monthly')}>Monthly</button>
<button aria-pressed={frequency === 'yearly'} onClick={() => setFrequency('yearly')}>Yearly</button>
</div>
<PricingTable
packages={packages}
frequency={frequency}
onSelect={(id) => { setSelected(id); /* forward to analytics */ }}
/>
</div>
)
}
Designing the toggle for accessibility
Use native buttons with aria-pressed to indicate toggle state, or implement a proper switch role with keyboard support.
Ensure labels are programmatic (aria-label or visible text). Screen readers should announce the current frequency clearly.
CTA handlers and analytics
Expose an onSelect(pkgId) callback from the card or table so parents can handle routing, open a modal, or send analytics events.
Keep CTA click handlers lightweight and idempotent; avoid side effects in render.
Selection patterns
For selectable plans, track selectedId in the parent and pass selected or isSelected to cards. Cards can visually indicate selection and expose a onSelect callback.
For accessibility, when a card is selected, set aria-pressed or aria-selected on an interactive control and ensure keyboard focus order is logical.
Warning
Avoid storing both the canonical billing frequency and computed displayed prices in multiple places. Keep one source of truth (frequency) and derive prices during render to prevent drift.
Further Reading
Accessibility is essential for pricing UIs: people using screen readers, keyboard-only navigation, or assistive tech must understand plans, prices, and actions (CTAs) clearly. Use semantic HTML, clear focus management, and ARIA only when semantics fall short.
Start with structure: wrap the whole pricing rows in a meaningful container (section with an accessible heading) and use lists for collections of cards. Each card is an article with an explicit heading and a summary of the price and features.
Example semantic structure:
<section aria-labelledby="pricing-heading">
<h2 id="pricing-heading">Choose a plan</h2>
<ul class="pricing-list">
<li>
<article aria-labelledby="basic-title">
<h3 id="basic-title">Basic</h3>
<p class="price">$10<span>/mo</span></p>
<ul class="features">...</ul>
<a role="button" href="/signup?plan=basic">Get Basic</a>
</article>
</li>
</ul>
</section>
Keyboard and focus:
Make CTAs native interactive elements (button or anchor) to preserve keyboard behavior. Avoid using divs with click handlers.
Ensure a logical DOM order that matches visual order so tabbing flows predictably.
Provide clear, visible focus outlines; don't rely on color alone.
ARIA and dynamic controls:
Use aria-pressed for toggle buttons (monthly/yearly). If you use a custom switch, ensure it has role="switch" and aria-checked.
Avoid over-using ARIA. When native elements can convey semantics, prefer them.
Example toggle (accessible):
<button
role="switch"
aria-checked={isYearly}
aria-label="Toggle billing period"
onClick={toggleBilling}
>
{isYearly ? 'Yearly' : 'Monthly'}
</button>
Screen reader considerations:
Announce price changes when the billing period toggles. Use a visually-hidden live region to inform assistive tech of updates.
<span aria-live="polite" class="visually-hidden">Pricing updated: $20 per year</span>
Testing checklist (quick):
Keyboard: Tab through all interactive controls, ensure order and activation work.
Screen reader: Narrate headings, prices, and CTAs. Toggle billing and ensure announcements.
Focus: visible focus for every interactive item.
Tip Use semantic headings (h2/h3) so users and AT can scan plans quickly.
Fact Native buttons and links provide keyboard, focus, and announcement behavior out of the box.
Warning Don't rely on color alone to convey differences (e.g., featured plan). Add text or icons and expose them to assistive tech.
Further Reading
Harden your component's API with TypeScript: use discriminated unions for mutually exclusive feature flags, utility types to mark optional fields, and generics to accept different feature shapes from upstream data.
Type modeling example:
type BillingCycle = 'monthly' | 'yearly';
type BaseFeature = { id: string; label: string };
type SimpleFeature = BaseFeature & { type: 'simple' };
type LimitFeature = BaseFeature & { type: 'limit'; limit: number };
type Feature = SimpleFeature | LimitFeature; // discriminated union
type Package<T extends Feature[] = Feature[]> = {
id: string;
name: string;
priceMonthly: number;
priceYearly?: number; // optional if only monthly is supported
features: T;
};
interface PricingTableProps<P extends Package = Package> {
plans: P[];
billing?: BillingCycle;
onSelect?: (planId: string, cycle: BillingCycle) => void;
}
This pattern ensures feature types are explicit and the component consumer gets strong typing.
Fetching from an API or headless CMS
Use React Query (or SWR) to fetch and cache pricing data. Normalize CMS shapes into your Package contract before passing to the component.
import { useQuery } from '@tanstack/react-query';
function usePricing() {
return useQuery(['pricing'], async () => {
const res = await fetch('/api/pricing');
if (!res.ok) throw new Error('Failed to load');
const raw = await res.json();
// map/normalize upstream format to Package[]
return raw.items.map(mapCmsToPackage);
});
}
Handle UI states:
Loading: skeletons or spinners in place of cards.
Error: friendly retry CTA and fallback static plans.
Fallback: local default JSON bundled with the app.
Example component usage:
const { data, isLoading, error } = usePricing();
if (isLoading) return <PricingSkeleton />;
if (error) return <ErrorBanner onRetry={refetch} />;
return <PricingTable plans={data} billing={billing} />;
Tip Normalize CMS data once (in a hook or service) so the component receives a single stable contract.
Fact React Query provides caching, background refetching, and stale-while-revalidate semantics that are ideal for pricing data.
Warning Don't render raw HTML from CMS in price fields—sanitize to prevent XSS and prefer structured data instead.
Further Reading
Performance
Avoid unnecessary re-renders: memoize pure plan cards with React.memo and prop-stable keys. Use useCallback for handlers passed to many cards.
Prevent expensive computations in render; derive prices outside render or memoize with useMemo.
Code-split: lazy-load heavy visuals (SVG libraries or animations) for the pricing page.
Image optimization: serve SVG for vector icons; use next/image or responsive srcsets for bitmap badges.
Example memoization:
const PlanCard = React.memo(function PlanCard({ plan, onSelect }: Props) {
// renders plan
});
// in parent
const handleSelect = useCallback((id) => onSelect(id, billing), [onSelect, billing]);
Testing
Unit tests: render each plan state (featured, disabled) and assert labels, pricing, and features.
Interaction tests: use React Testing Library to simulate keyboard navigation, toggle billing, and click CTAs.
Accessibility tests: integrate axe-core or jest-axe to run automated checks on the rendered markup.
Example RTL test sketch:
it('toggles billing and announces price changes', async () => {
render(<PricingTable plans={plans} />);
const toggle = screen.getByRole('switch');
userEvent.click(toggle);
expect(await screen.findByText(/per year/i)).toBeInTheDocument();
});
Production and distribution
Build for tree-shaking: export named components and avoid side effects.
Provide TypeScript declarations (.d.ts) when publishing an npm package.
Version and changelog for consumers; follow semantic versioning.
Tip Run a bundle analyzer on your published package to keep size minimal and detect heavy deps.
Fact Automated accessibility tests catch many issues but should be complemented by manual screen reader checks.
ieldset>
Warning Over-optimizing early (premature memoization or virtualization) can complicate code. Profile first with React DevTools.
Further Reading
Scenario: integrate the PricingTable into a SaaS landing page with a headless CMS for plan content and a theme toggle.
Integration steps:
CMS authoring: define a pricing content type with fields: id, name, monthlyPrice, yearlyPrice, features (structured list), isFeatured, badges.
Fetch and map: use a server-side route (or getStaticProps for SSR) to fetch CMS plans, map them to the internal Package type, and pass them to the page.
Theme and responsiveness: consume theme context to style featured plans; ensure CSS Grid rearranges cards for small screens.
Analytics and A/B: wrap onSelect with an analytics hook to track clicks and run experiments on which plan copy converts better.
Example mapping workflow:
// pages/index.tsx (Next.js)
export async function getStaticProps() {
const raw = await fetchCmsPlans();
const plans = raw.map(mapCmsToPackage);
return { props: { plans }, revalidate: 60 };
}
Maintenance tips:
Single source of truth: keep normalization code in one place so changes in CMS schema require only one update.
Tests: include an integration test that mounts the PricingTable with mocked API responses.
Governance: document styles, props, and accessibility requirements in your component README inside the design system repo.
Extension ideas:
Billing flow integration: emit structured events with plan id and billing cycle for payment provider flows.
Localization: externalize currency and unit formatting using Intl APIs.
Analytics hooks: place a lightweight hook for conversion tracking but allow consumers to opt-in.
Tip Expose a small adapter API so consumers can map their CMS objects to the component contract with minimal code.
Fact Serving pricing from a CMS is fine, but use ISR or caching to avoid exposing editors' intermediate states in production pages.
Warning Don't store sensitive pricing rules or coupons in client-side CMS fields—keep calculations and secrets server-side.
Further Reading
You're ready to ship a production-ready, reusable pricing table. Next steps to prioritize:
Publish: package the component, add TypeScript types, set up semantic-release and a private or public npm registry.
CI: add linting, type checks, tests, and a11y checks to CI (GitHub Actions recommended).
Localization: wire currency and pluralization into Intl and externalize copy for translators.
Billing integration: add analytics and a clear event contract for the payment flow.
Open-source review: consider open-sourcing a component to gather feedback and contributions.
Production readiness checklist
Final notes: prioritize accessibility and predictable API contracts—these increase adoption and reduce support overhead. Keep the component small, well-documented, and easy to adapt.
Tip Include runnable examples (CodeSandbox or Storybook) so designers and engineers can preview states and copy.
Fact A well-documented reusable component reduces duplicates and inconsistencies across product pages.
Warning Don't skip manual accessibility testing—automated tools are helpful but not sufficient.
Further Reading
Prateeksha Web Design helps businesses turn tutorials like "Reusable Pricing Table in React: Build a Component for Service Packages" into real-world results with custom websites, performance optimization, and automation. From strategy to implementation, our team supports you at every stage of your digital journey.
Chat with us now Contact us today .