Files
supabase/apps/www/components/Pricing/PricingTableRow.tsx
Pamela Chia 6d4263bf1a fix(www): pricing table spacing and sticky offset (#49532)
Multi-line cells in the /pricing comparison table rendered as one
cramped block once lines wrapped: the stacked values had no gap between
entries, and the continuation lines' `leading-4` is a no-op at
`text-xs`. At the same narrow desktop widths (1024 to 1280px), category
headers clipped behind the sticky plan header because their hardcoded
`top-[108px] xl:top-[84px]` offsets desync from the plan header's
variable height.

**Changed:**
- **Value cells get vertical padding**: the value tds were `pl-6 pr-2`
with no vertical padding, so tall multi-line cells pressed text against
the row dividers; they now carry `py-5` like the row-label column (text
ink sits ~21px off both borders, measured).
- **Stacked price lines read as separate entries**: `gap-2` between
entries while wrapped lines inside one entry stay tight (`leading-4`),
so each price reads as its own block. Two earlier iterations that only
widened the entry gap (`gap-1`, then `gap-2` with looser `leading-5`
wraps) reviewed as still-cramped; the missing cell padding was the
dominant cause. Mobile untouched.
- **Category headers never clip behind the plan header**: a
ResizeObserver measures the sticky thead's real height and publishes
`--pricing-category-top` as a CSS variable on the table; the category
header consumes it via `var()`, keeping the old 108px/84px values as
pre-hydration fallbacks so SSR paint is unchanged. Runs in a layout
effect so the first client paint already has the measured value.

**Note:** I rejected re-tuning the hardcoded offsets: numbers desyncing
from the header's content-driven height is the root cause, and new
constants re-break on the next copy or breakpoint change.

## Before / after

**Before** (prod: multi-line prices pressed against the row dividers as
one dense block; rows clipped under the sticky category header):
<img width="1442" height="450" alt="pr-before"
src="https://github.com/user-attachments/assets/91d428df-e04c-4494-b454-84a8a46b3ddd"
/>

**After** (this PR: padded cells, each price its own block, headers pin
flush):
<img width="1497" height="375" alt="pr-after"
src="https://github.com/user-attachments/assets/5aa2c3c7-b41e-42c0-8159-bb294a5d1dea"
/>

## To test

Tested on the Vercel preview (Playwright, measured values in parens):
- [x] At a 1024 to 1280px viewport, open /pricing and find the Pipelines
row: the 3 price lines in the Pro and Team cells read as distinct blocks
(row-gap 8px between entries, tight 16px line boxes within an entry,
20px cell padding; verified on localhost pre-push and on the preview,
light and dark)
- [x] Multi-line cell text no longer touches the row dividers: ~21px
from border to text ink top and bottom (was 0-3px)
- [x] At the same width, scroll the whole comparison table: Database and
Auth section headers pin flush below the plan-price header, with no row
text clipped between them (pinned category top within 0.2px of thead
bottom)
- [x] At ~1800px wide: Pipelines and Point in time recovery (Enterprise)
cells show the same separated lines (PITR Enterprise is a single
wrapping string, renders cleanly)
- [x] Resize across 1280px after load: category headers stay flush under
the plan header (ResizeObserver refires; same 0.2px alignment after
1900px to 1150px resize without reload)
- [x] Below 1024px: the mobile comparison view is unchanged
- [x] Added: cold navigation to /pricing#compare-plans lands with
`--pricing-category-top` already applied (151px at 1150px width) and the
pinned header flush
- [x] Added: no new console errors versus the page's pre-existing
baseline

## Linear
- fixes GROWTH-1137
2026-08-26 15:11:15 +08:00

322 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ProductIcon from 'components/ProductIcon'
import Link from 'next/link'
import { Fragment, ReactNode } from 'react'
import { FeatureKey } from 'shared-data/pricing'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { IconPricingIncludedCheck, IconPricingMinus } from './PricingIcons'
type PricingTooltips = {
[key in FeatureKey]?: {
main?: ReactNode
pro?: ReactNode
team?: ReactNode
enterprise?: ReactNode
}
}
export const pricingTooltips: PricingTooltips = {
'database.dedicatedPostgresDatabase': {
main: 'A Postgres database with no restrictions? You get it. No pseudo limited users, you are the postgres root user. No caveats.',
},
'database.size': {
main: 'Billing is based on the provisioned disk size. Paid plan projects get provisioned with 8 GB of disk by default and autoscale to 1.5x the size once you get close to the limit. The first 8 GB of disk per project comes with no additional fees.\nFree plan customers are limited to 500 MB database size per project.',
},
'database.advancedDiskConfig': {
main: 'Supabase databases are backed by high performance SSD disks. The disk can be scaled up to 60 TB, 80,000 IOPS and 4,000 MB/s throughput.',
},
'database.automaticBackups': {
main: 'Backups are entire copies of your database that can be restored in the future.',
pro: '7 days of backup (if > 1 TB, contact for Enterprise pricing)',
team: '14 days of backup (if > 1 TB, contact for Enterprise pricing)',
},
'database.pitr': {
main: 'PITR cannot be applied retroactively, projects can only be rolled back to the point from which PITR has been applied.',
},
'database.pausing': {
main: 'Projects that have no activity or API requests will be paused. They can be reactivated via the dashboard.',
},
'database.egress': {
main: 'Billing is based on the total sum of all outgoing traffic (includes Database, Storage, Realtime, Auth, API, Edge Functions, Supavisor, Log Drains) in GB throughout your billing period. Excludes cache hits.',
},
'database.replication': {
main: 'Supabase Pipelines replicates Postgres tables to analytical destinations.\nBilling is based on configured pipeline hours and Postgres row data processed during initial sync and ongoing replication. Pipeline-hour billing ends when the pipeline is deleted.',
},
'storage.cachedEgress': {
main: 'Billing is based on the total sum of outgoing Storage traffic in GB throughout your billing period that is served from our CDN cache.',
},
'auth.totalUsers': {
main: 'The maximum number of users your project can have',
},
'auth.maus': {
main: 'Users who log in or refresh their token count towards MAU.\nBilling is based on the sum of distinct users requesting your API throughout the billing period. Resets every billing cycle.',
},
'auth.userDataOwnership': {
main: 'Full ownership and access to the underlying user data including encrypted passwords.',
},
'auth.anonSignIns': {
main: 'Anonymous user requests count towards MAU, just like a permanent user.',
},
'auth.basicMFA': {
main: 'Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), using Time-based One Time Passwords (TOTP) via an Authenticator App.',
},
'auth.advancedMFAPhone': {
main: 'Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), using SMS or WhatsApp messages.\nAdditional fees apply based on your provider.',
},
'auth.thirdPartyMAUs': {
main: 'Users who use the Supabase platform through a third-party authentication provider (Firebase Auth, Auth0 or Cognito).\nBilling is based on the sum of distinct third-party users requesting your API through the billing period. Resets every billing cycle.',
},
'storage.size': {
main: "The sum of all objects' size in your storage buckets.\nBilling is prorated down to the hour and will be displayed as GB-Hrs on your invoice.",
},
'storage.maxFileSize': {
main: 'You can change the upload size in the dashboard',
},
'storage.cdn': {
main: 'Assets in Storage are automatically cached on a CDN. With Smart CDN caching enabled, the CDN cache is automatically re-validated when the underlying asset changes. CDN caching is enabled across all plans and assets in the paid plans are cached via the Smart CDN.',
},
'storage.transformations': {
main: 'We count all images that were transformed in the billing period, ignoring any transformations.\nUsage example: You transform one image with four different size transformations and another image with just a single transformation. It counts as two, as only two images were transformed.\nBilling is based on the count of (origin) images that used transformations throughout the billing period. Resets every billing cycle.',
},
'functions.invocations': {
main: 'Billing is based on the sum of all invocations, independent of response status, throughout your billing period.',
},
'realtime.concurrentConnections': {
main: 'Total number of successful connections. Connections attempts are not counted towards usage.\nBilling is based on the maximum amount of concurrent peak connections throughout your billing period.',
},
'realtime.messagesPerMonth': {
main: "Count of messages going through Realtime. Includes database changes, broadcast and presence. \nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.",
},
'security.logDrain': {
main: 'Only events processed and sent to destinations are counted. Egress required to export logs count towards usage.\nEgress through Log Drains is rolled up into the unified egress and benefits from the unified egress quota.',
},
'security.hipaa': {
main: 'Available as a paid add-on on Team Plan and above.',
},
'security.privateLink': {
main: (
<span className="prose text-xs">
AWS PrivateLink enables private connectivity between your AWS VPC and Supabase, keeping
traffic within the AWS network. Read more in our{' '}
<Link href="/docs/guides/platform/privatelink" target="_blank">
docs
</Link>
.
</span>
),
},
'security.accessRoles': {
main: (
<span className="prose text-xs">
Supabase provides granular access controls to manage permissions across your organizations
and projects. Read more in our{' '}
<Link href="/docs/guides/platform/access-control" target="_blank">
docs
</Link>
.
</span>
),
},
'security.customDomains': {
enterprise: 'Volume discounts available.',
},
'auth.auditLogs': {
main: (
<span className="prose text-xs">
Auth Audit Logs provide comprehensive tracking of authentication events. Audit logs are
automatically captured for all authentication events and help you monitor user
authentication activities, detect suspicious behavior, and maintain compliance with security
requirements. Read more in our{' '}
<Link href="/docs/guides/auth/audit-logs" target="_blank">
docs
</Link>
.
</span>
),
},
'security.platformAuditLogs': {
main: (
<span className="prose text-xs">
Any Platform API/Dashboard actions performed by organization members are logged
automatically for auditing and security purposes. Includes actions such as creating a new
project, inviting members or changing project settings. Read more in our{' '}
<Link href="/docs/guides/security/platform-audit-logs" target="_blank">
docs
</Link>
.
</span>
),
},
}
export const PricingTableRowDesktop = (props: any) => {
const category = props.category
return (
<>
<tr
className="divide-border -scroll-mt-5"
style={{ borderTop: 'none' }}
id={`${props.sectionId}-desktop`}
>
{/* 108px/84px are pre-hydration fallbacks only; after mount PricingComparisonTable
measures the sticky thead into --pricing-category-top and that value wins */}
<th
className="bg-background text-foreground sticky top-[var(--pricing-category-top,108px)] xl:top-[var(--pricing-category-top,84px)] z-10 py-3 pl-6 text-left text-sm font-medium"
scope="colgroup"
>
<div className="flex items-center gap-4">
{props.icon && <ProductIcon icon={props.icon} color="green" />}
<h4 className="m-0 text-base font-normal">{category.title}</h4>
</div>
</th>
<td className="bg-background px-6 py-5 free"></td>
<td className="bg-background px-6 py-5 pro"></td>
<td className="bg-background px-6 py-5 team"></td>
<td className="bg-background px-6 py-5 enterprise"></td>
</tr>
{category.features.map((feat: any, i: number) => {
const tooltips = pricingTooltips[feat.key as FeatureKey]
return (
<Fragment key={feat.title}>
<tr className="divide-border" key={i}>
<th
className={`text-foreground flex items-center px-6 py-5 last:pb-24 text-left text-xs font-normal `}
scope="row"
>
<span className="mr-1">{feat.title}</span>
{tooltips?.main && (
<InfoTooltip side="top" className="max-w-[250px]" label={`About ${feat.title}`}>
{tooltips.main}
</InfoTooltip>
)}
</th>
{Object.entries(feat.plans).map((entry: any, i) => {
const planName = entry[0] as 'pro' | 'team' | 'enterprise'
const planValue = entry[1]
return (
<td
key={i}
className={[
`pl-6 pr-2 py-5 tier-${planName}`,
typeof planValue === 'boolean' ? 'text-center' : '',
].join(' ')}
>
{typeof planValue === 'boolean' && planValue === true ? (
<IconPricingIncludedCheck plan={planValue} />
) : typeof planValue === 'boolean' && planValue === false ? (
<div className="text-muted">
<IconPricingMinus plan={planValue} />
</div>
) : (
<div className="text-foreground text-xs flex flex-col justify-center gap-2">
<span className="flex items-center gap-2">
{tooltips?.[planName] && (
<InfoTooltip
side="top"
className="max-w-[250px]"
label={`About ${feat.title} on the ${planName} plan`}
>
{tooltips[planName]}
</InfoTooltip>
)}
{typeof planValue === 'string' ? planValue : planValue[0]}
</span>
{Array.isArray(planValue) &&
planValue.slice(1).map((val, idx) => (
<span key={`planval_${i}_${idx}`} className="text-lighter leading-4">
{val}
</span>
))}
</div>
)}
</td>
)
})}
</tr>
{i === category.features.length - 1 && (
<tr className="my-16 bg-green-400 border-none"></tr>
)}
</Fragment>
)
})}
</>
)
}
export const PricingTableRowMobile = (props: any) => {
const category = props.category
const plan = props.plan
return (
<table className="mt-8 w-full -scroll-mt-5" id={`${props.sectionId}-mobile`}>
<caption className="bg-background border-default border-t px-4 py-3 text-left text-sm font-medium text-foreground">
<span className="flex items-center gap-2">
{category.icon ? <ProductIcon icon={props.icon} /> : null}
<span className="text-foreground font-normal">{category.title}</span>
</span>
</caption>
<thead>
<tr>
<th className="sr-only" scope="col">
Feature
</th>
<th className="sr-only" scope="col">
Included
</th>
</tr>
</thead>
<tbody className="divide-border-default divide-y">
{category.features.map((feat: any, i: number) => {
return (
<tr key={i} className="border-default border-t">
<th
className="text-foreground-light px-4 py-3 text-left text-sm font-normal"
scope="row"
>
<p>{feat.title}</p>
</th>
<td className="py-3 pr-4 text-right">
{typeof feat.plans[plan] === 'boolean' && feat.plans[plan] === true ? (
<span className="inline-block">
<IconPricingIncludedCheck plan={plan} />
</span>
) : typeof feat.plans[plan] === 'boolean' && feat.plans[plan] === false ? (
<span className="inline-block">
<IconPricingMinus plan={plan} />
</span>
) : (
<span className="text-foreground flex flex-col text-sm">
<span>
{typeof feat.plans[plan] === 'string'
? feat.plans[plan]
: feat.plans[plan][0]}
</span>
{Array.isArray(feat.plans[plan]) &&
feat.plans[plan].slice(1).map((val, idx) => (
<span key={`planval_mobile_${i}_${idx}`} className="text-lighter leading-5">
{val}
</span>
))}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
)
}