Large table 02
A heading over a plan comparison table with plan icons and the middle plan highlighted.
Last updated
---
/**
* Large table 02: A heading over a plan comparison table with plan icons and the middle plan highlighted.
*
* @block pricing
* @order 22
*/
import { CircleCheck, CircleHelp, Layers2, Layers3, Minus, Zap } from '@lucide/astro'
import Section from '@/components/apogee/Section/Section.astro'
import SectionHeader from '@/components/apogee/SectionHeader/SectionHeader.astro'
import Button from '@/components/apogee/Button/Button.astro'
import FeaturedIcon from '@/components/apogee/FeaturedIcon/FeaturedIcon.astro'
import Table from '@/components/apogee/Table/Table.astro'
import Tooltip from '@/components/apogee/Tooltip/Tooltip.astro'
import { plain } from '@/components/apogee/sections/_shared/spine.ts'
import type { PricingContent } from '@/components/apogee/sections/_shared/pricing.ts'
/** @composable */
interface Props extends PricingContent {
/** 1 when the block opens a page. */
headingLevel?: 1 | 2
/** Feature groups; each row's `values` follow `items` in order: `true` included, `false` not, text as written. The first group's name is for screen readers only. @shape { name, rows: { name, tip?, values: (string | boolean)[] }[] } @category content */
comparison?: { name: string; rows: { name: string; tip?: string; values: (string | boolean)[] }[] }[]
}
const {
headingLevel = 2,
eyebrow = 'Pricing',
heading = 'Simple, transparent pricing',
description = 'We believe good tools should be open to every company, whatever its size.',
items = [
{ name: 'Basic plan', price: '$10', period: 'month', description: 'Our most popular plan.', features: [], action: { label: 'Get started', href: '#' } },
{ name: 'Business plan', price: '$20', period: 'month', description: 'Best for growing teams.', features: [], action: { label: 'Get started', href: '#' }, featured: true },
{ name: 'Enterprise plan', price: '$40', period: 'month', description: 'Best for large teams.', features: [], action: { label: 'Get started', href: '#' } },
],
comparison = [
{ name: 'Overview', rows: [
{ name: 'Basic features', tip: 'Core tools you need to manage your workspace and get started.', values: [true, true, true] },
{ name: 'Users', tip: 'Add, remove and manage people, with control over their access.', values: ['10', '20', 'Unlimited'] },
{ name: 'Individual data', tip: 'Data and activity for each person in your workspace.', values: ['20 GB', '40 GB', 'Unlimited'] },
{ name: 'Support', tip: 'Help centre, email and in-app help during business hours.', values: [true, true, true] },
{ name: 'Automated workflows', tip: 'Automate repeated tasks to save time and effort.', values: [false, true, true] },
{ name: '200+ integrations', tip: 'Connect the tools you already use.', values: [false, true, true] },
] },
{ name: 'Reporting and analytics', rows: [
{ name: 'Analytics', tip: 'Trends, usage and key numbers across your account.', values: ['Basic', 'Advanced', 'Advanced'] },
{ name: 'Export reports', tip: 'Download reports as CSV or PDF.', values: [true, true, true] },
{ name: 'Scheduled reports', tip: 'Reports built and emailed on a schedule.', values: [true, true, true] },
{ name: 'API access', tip: 'Work with your data, users and workflows from code.', values: [false, true, true] },
{ name: 'Advanced reports', tip: 'Custom charts, filters and drill-downs.', values: [false, true, true] },
{ name: 'Saved reports', tip: 'Save report settings you use often.', values: [false, true, true] },
{ name: 'Customer properties', tip: 'Your own data points for each customer.', values: [false, false, true] },
{ name: 'Custom fields', tip: 'Add fields to users, reports or properties.', values: [false, false, true] },
] },
{ name: 'User access', rows: [
{ name: 'SSO and SAML sign-in', tip: 'Single sign-on with your identity provider.', values: [true, true, true] },
{ name: 'Advanced permissions', tip: 'Choose who can view, edit or manage each area.', values: [false, true, true] },
{ name: 'Audit log', tip: 'A history of actions and changes across your organisation.', values: [false, false, true] },
{ name: 'Data history', tip: 'See changes and trends over time.', values: [false, false, true] },
] },
],
} = Astro.props
const PlanHeading = headingLevel === 1 ? 'h2' : 'h3'
const icons = [Zap, Layers2, Layers3]
// A featured plan's column is tinted down the whole wide table.
const tint = (i: number) => (items[i]?.featured ? ' bg-primary-faded/40' : '')
---
<Section spacing="large">
<div class="flex flex-col gap-12 md:gap-16">
<SectionHeader headingLevel={headingLevel} eyebrow={eyebrow ?? undefined} heading={plain(heading)} description={description ?? undefined} size="large" />
<div class="max-lg:hidden">
<Table size="medium" striped class="table-fixed">
<thead>
<tr>
<td class="w-1/4"></td>
{items.map((plan, i) => {
const Icon = icons[i % icons.length]!
return (
<th scope="col" class={`w-1/4 align-top${tint(i)}`}>
<div class="flex flex-col gap-4 py-2 text-start font-normal">
<FeaturedIcon theme="outline" color="neutral" size="medium"><Icon /></FeaturedIcon><p class="flex items-center gap-2 text-xl font-semibold">{plan.name}</p>
<p class="flex items-baseline gap-1"><span class="text-5xl font-semibold tracking-tight tabular-nums">{plan.price}</span>{plan.period && <span class="text-neutral-faded">per {plan.period}</span>}</p>
<p class="text-sm text-neutral-faded">{plan.description}</p>
<Button href={plan.action.href} fullWidth>{plan.action.label}</Button>
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{comparison.map((group, gi) => (
<>
<tr><th scope="colgroup" colspan={items.length + 1} class={gi === 0 ? 'sr-only' : 'pt-8 text-sm font-semibold text-primary'}>{group.name}</th></tr>
{group.rows.map((row) => (
<tr><th scope="row" class="font-medium"><span class="inline-flex items-center gap-1.5">{row.name}{row.tip && <Tooltip text={row.tip}><button type="button" class="rounded-full text-neutral-faded hover:text-neutral focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus" aria-label={`About ${row.name.toLowerCase()}`}><CircleHelp class="size-4" aria-hidden="true" /></button></Tooltip>}</span></th>{row.values.map((v, i) => <td class={`text-center${tint(i)}`}>{v === true ? <CircleCheck class="mx-auto size-5 text-positive" aria-label="Included" /> : v === false ? <Minus class="mx-auto size-5 text-neutral-faded" aria-label="Not included" /> : v}</td>)}</tr>
))}
</>
))}
</tbody>
</Table>
</div>
<div class="flex flex-col gap-16 lg:hidden">
{items.map((plan, pi) => (
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3">
<PlanHeading class="flex items-center gap-2 text-xl font-semibold">{plan.name}</PlanHeading>
<p class="flex items-baseline gap-1"><span class="text-5xl font-semibold tracking-tight tabular-nums">{plan.price}</span>{plan.period && <span class="text-neutral-faded">per {plan.period}</span>}</p>
<p class="text-sm text-neutral-faded">{plan.description}</p>
<Button href={plan.action.href} fullWidth>{plan.action.label}</Button>
</div>
{comparison.map((group, gi) => (
<Table size="small" striped><caption class={gi === 0 ? 'sr-only' : 'pb-2 text-start text-sm font-semibold text-primary'}>{group.name}</caption><tbody>{group.rows.map((row) => { const v = row.values[pi]; return <tr><th scope="row" class="font-medium">{row.name}</th><td class="text-end">{v === true ? <CircleCheck class="ms-auto size-5 text-positive" aria-label="Included" /> : v === false ? <Minus class="ms-auto size-5 text-neutral-faded" aria-label="Not included" /> : v}</td></tr> })}</tbody></Table>
))}
</div>
))}
</div>
</div>
</Section>
Show codeHide code
---
/**
* Large table 02: A heading over a plan comparison table with plan icons and the middle plan highlighted.
*
* @block pricing
* @order 22
*/
import { CircleCheck, CircleHelp, Layers2, Layers3, Minus, Zap } from '@lucide/astro'
import Section from '@/components/apogee/Section/Section.astro'
import SectionHeader from '@/components/apogee/SectionHeader/SectionHeader.astro'
import Button from '@/components/apogee/Button/Button.astro'
import FeaturedIcon from '@/components/apogee/FeaturedIcon/FeaturedIcon.astro'
import Table from '@/components/apogee/Table/Table.astro'
import Tooltip from '@/components/apogee/Tooltip/Tooltip.astro'
import { plain } from '@/components/apogee/sections/_shared/spine.ts'
import type { PricingContent } from '@/components/apogee/sections/_shared/pricing.ts'
/** @composable */
interface Props extends PricingContent {
/** 1 when the block opens a page. */
headingLevel?: 1 | 2
/** Feature groups; each row's `values` follow `items` in order: `true` included, `false` not, text as written. The first group's name is for screen readers only. @shape { name, rows: { name, tip?, values: (string | boolean)[] }[] } @category content */
comparison?: { name: string; rows: { name: string; tip?: string; values: (string | boolean)[] }[] }[]
}
const {
headingLevel = 2,
eyebrow = 'Pricing',
heading = 'Simple, transparent pricing',
description = 'We believe good tools should be open to every company, whatever its size.',
items = [
{ name: 'Basic plan', price: '$10', period: 'month', description: 'Our most popular plan.', features: [], action: { label: 'Get started', href: '#' } },
{ name: 'Business plan', price: '$20', period: 'month', description: 'Best for growing teams.', features: [], action: { label: 'Get started', href: '#' }, featured: true },
{ name: 'Enterprise plan', price: '$40', period: 'month', description: 'Best for large teams.', features: [], action: { label: 'Get started', href: '#' } },
],
comparison = [
{ name: 'Overview', rows: [
{ name: 'Basic features', tip: 'Core tools you need to manage your workspace and get started.', values: [true, true, true] },
{ name: 'Users', tip: 'Add, remove and manage people, with control over their access.', values: ['10', '20', 'Unlimited'] },
{ name: 'Individual data', tip: 'Data and activity for each person in your workspace.', values: ['20 GB', '40 GB', 'Unlimited'] },
{ name: 'Support', tip: 'Help centre, email and in-app help during business hours.', values: [true, true, true] },
{ name: 'Automated workflows', tip: 'Automate repeated tasks to save time and effort.', values: [false, true, true] },
{ name: '200+ integrations', tip: 'Connect the tools you already use.', values: [false, true, true] },
] },
{ name: 'Reporting and analytics', rows: [
{ name: 'Analytics', tip: 'Trends, usage and key numbers across your account.', values: ['Basic', 'Advanced', 'Advanced'] },
{ name: 'Export reports', tip: 'Download reports as CSV or PDF.', values: [true, true, true] },
{ name: 'Scheduled reports', tip: 'Reports built and emailed on a schedule.', values: [true, true, true] },
{ name: 'API access', tip: 'Work with your data, users and workflows from code.', values: [false, true, true] },
{ name: 'Advanced reports', tip: 'Custom charts, filters and drill-downs.', values: [false, true, true] },
{ name: 'Saved reports', tip: 'Save report settings you use often.', values: [false, true, true] },
{ name: 'Customer properties', tip: 'Your own data points for each customer.', values: [false, false, true] },
{ name: 'Custom fields', tip: 'Add fields to users, reports or properties.', values: [false, false, true] },
] },
{ name: 'User access', rows: [
{ name: 'SSO and SAML sign-in', tip: 'Single sign-on with your identity provider.', values: [true, true, true] },
{ name: 'Advanced permissions', tip: 'Choose who can view, edit or manage each area.', values: [false, true, true] },
{ name: 'Audit log', tip: 'A history of actions and changes across your organisation.', values: [false, false, true] },
{ name: 'Data history', tip: 'See changes and trends over time.', values: [false, false, true] },
] },
],
} = Astro.props
const PlanHeading = headingLevel === 1 ? 'h2' : 'h3'
const icons = [Zap, Layers2, Layers3]
// A featured plan's column is tinted down the whole wide table.
const tint = (i: number) => (items[i]?.featured ? ' bg-primary-faded/40' : '')
---
<Section spacing="large">
<div class="flex flex-col gap-12 md:gap-16">
<SectionHeader headingLevel={headingLevel} eyebrow={eyebrow ?? undefined} heading={plain(heading)} description={description ?? undefined} size="large" />
<div class="max-lg:hidden">
<Table size="medium" striped class="table-fixed">
<thead>
<tr>
<td class="w-1/4"></td>
{items.map((plan, i) => {
const Icon = icons[i % icons.length]!
return (
<th scope="col" class={`w-1/4 align-top${tint(i)}`}>
<div class="flex flex-col gap-4 py-2 text-start font-normal">
<FeaturedIcon theme="outline" color="neutral" size="medium"><Icon /></FeaturedIcon><p class="flex items-center gap-2 text-xl font-semibold">{plan.name}</p>
<p class="flex items-baseline gap-1"><span class="text-5xl font-semibold tracking-tight tabular-nums">{plan.price}</span>{plan.period && <span class="text-neutral-faded">per {plan.period}</span>}</p>
<p class="text-sm text-neutral-faded">{plan.description}</p>
<Button href={plan.action.href} fullWidth>{plan.action.label}</Button>
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{comparison.map((group, gi) => (
<>
<tr><th scope="colgroup" colspan={items.length + 1} class={gi === 0 ? 'sr-only' : 'pt-8 text-sm font-semibold text-primary'}>{group.name}</th></tr>
{group.rows.map((row) => (
<tr><th scope="row" class="font-medium"><span class="inline-flex items-center gap-1.5">{row.name}{row.tip && <Tooltip text={row.tip}><button type="button" class="rounded-full text-neutral-faded hover:text-neutral focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus" aria-label={`About ${row.name.toLowerCase()}`}><CircleHelp class="size-4" aria-hidden="true" /></button></Tooltip>}</span></th>{row.values.map((v, i) => <td class={`text-center${tint(i)}`}>{v === true ? <CircleCheck class="mx-auto size-5 text-positive" aria-label="Included" /> : v === false ? <Minus class="mx-auto size-5 text-neutral-faded" aria-label="Not included" /> : v}</td>)}</tr>
))}
</>
))}
</tbody>
</Table>
</div>
<div class="flex flex-col gap-16 lg:hidden">
{items.map((plan, pi) => (
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3">
<PlanHeading class="flex items-center gap-2 text-xl font-semibold">{plan.name}</PlanHeading>
<p class="flex items-baseline gap-1"><span class="text-5xl font-semibold tracking-tight tabular-nums">{plan.price}</span>{plan.period && <span class="text-neutral-faded">per {plan.period}</span>}</p>
<p class="text-sm text-neutral-faded">{plan.description}</p>
<Button href={plan.action.href} fullWidth>{plan.action.label}</Button>
</div>
{comparison.map((group, gi) => (
<Table size="small" striped><caption class={gi === 0 ? 'sr-only' : 'pb-2 text-start text-sm font-semibold text-primary'}>{group.name}</caption><tbody>{group.rows.map((row) => { const v = row.values[pi]; return <tr><th scope="row" class="font-medium">{row.name}</th><td class="text-end">{v === true ? <CircleCheck class="ms-auto size-5 text-positive" aria-label="Included" /> : v === false ? <Minus class="ms-auto size-5 text-neutral-faded" aria-label="Not included" /> : v}</td></tr> })}</tbody></Table>
))}
</div>
))}
</div>
</div>
</Section>Add
npx @ava-ui/apogee add pricing/large-table-02
This copies the block to src/components/apogee/blocks/pricing/ with everything it uses: Button, FeaturedIcon, Section, SectionHeader, Table, Tooltip. Every piece of copy is a prop, with the sample copy as its default. Pass yours, or null to hide a part; see copy is props.
Accessibility
- The heading is an
h2by default, so the block slots into a page under itsh1; item titles are one level down.
Related
PricingPlan tiers side by side, with an optional monthly / annual switch.
SegmentedControlA row of connected, mutually exclusive options backed by native radios — pick one from a small, always-visible set.
TableA styled, horizontally-scrollable table. Write plain thead/tbody markup — Table only styles it.