Spectrum UI
blockbanner-tierssource: ok · hit
Banner Tiers
A brutalist plan table whose featured column wears a blue banner on top.
npx shadcn add "https://ui.spectrumhq.in/r/banner-tiers.json"free
Where it goes
data-tablebannerfeature-grid
Visual style
brutalist
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files4 · source cached 10/8/2026, 3:39:45 PM
components/spectrumui/blocks/pricing/banner-tiers.tsx
'use client';
import { useState } from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PriceFigure } from './price-figure';
import { REVEAL_KEYFRAMES, revealClass, revealDelay, useReveal } from './reveal';
import type { Plan } from './types';
export type BannerTiersVariant = 'Banner' | 'Badge';
export interface BannerTiersProps {
plans: Plan[];
eyebrow?: string;
heading: string;
variant?: BannerTiersVariant;
className?: string;
}
export function BannerTiers({
plans,
eyebrow = 'Pricing',
heading,
variant = 'Banner',
className,
}: BannerTiersProps) {
const [yearly, setYearly] = useState(true);
const { ref, shown } = useReveal<HTMLElement>();
const banner = variant === 'Banner';
const featuredIndex = Math.max(
0,
plans.findIndex((plan) => plan.featured),
);
return (
<section
ref={ref}
className={cn(
'@container w-full bg-white px-6 pb-10 pt-12 dark:bg-[#0c0c0e] sm:px-10',
className,
)}
>
<style dangerouslySetInnerHTML={{ __html: REVEAL_KEYFRAMES }} />
<div className={cn('flex flex-col items-center text-center', revealClass(shown))}>
<span className="flex items-center gap-2.5 border border-[#d4d4d8] px-3.5 py-2 dark:border-[#33333a]">
<span aria-hidden className="size-2 bg-[#2337f0]" />
<span className="font-mono text-[12px] font-medium uppercase tracking-[0.1em] text-[#18181b] dark:text-[#f4f4f5]">
{eyebrow}
</span>
</span>
<h2 className="mt-7 max-w-[24ch] text-balance [font-family:var(--font-spectral,Georgia,serif)] text-[26px] font-normal leading-[1.08] tracking-[-0.6px] text-[#0a0a0a] dark:text-white @sm:text-[34px] @lg:text-[44px]">
{heading}
</h2>
<div className="mt-8 flex items-center gap-3">
<span
className={cn(
'text-[15px]',
yearly ? 'text-[#0a0a0a] dark:text-white' : 'text-[#71717a] dark:text-[#8f8f96]',
)}
>
Yearly
</span>
<button
type="button"
role="switch"
aria-checked={!yearly}
aria-label="Bill monthly"
onClick={() => setYearly((value) => !value)}
className={cn(
'relative h-7 w-[74px] border border-[#c9c9cf] bg-white dark:border-[#3f3f46] dark:bg-[#1a1a1e]',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#2337f0] focus-visible:ring-offset-2',
)}
>
<span
aria-hidden
className={cn(
'absolute top-0.5 left-0.5 h-[22px] w-[33px] bg-[#2337f0]',
'transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none',
!yearly && 'translate-x-[35px]',
)}
/>
</button>
<span
className={cn(
'text-[15px]',
!yearly ? 'text-[#0a0a0a] dark:text-white' : 'text-[#71717a] dark:text-[#8f8f96]',
)}
>
Monthly
</span>
</div>
</div>
<div className={cn('grid @3xl:grid-cols-3', banner ? 'mt-24' : 'mt-12')}>
{plans.map((plan, position) => {
const featured = plan.featured;
const amount = plan.price ? (yearly ? plan.price.annual : plan.price.monthly) : null;
return (
<div
key={plan.id}
style={revealDelay(shown, position)}
className={cn(
'relative flex flex-col border border-[#e4e4e7] dark:border-[#2b2b31]',
revealClass(shown),
'mt-14 first:mt-0 @3xl:mt-0',
position < featuredIndex && '@3xl:border-r-0',
position > featuredIndex && '@3xl:border-l-0',
featured && 'z-10 border-[#2337f0] dark:border-[#4356ff]',
)}
>
{featured && banner && (
<div className="absolute inset-x-[-1px] -top-12 flex h-12 items-center justify-center bg-[#2337f0] text-[19px] font-medium text-white dark:bg-[#4356ff]">
Most popular
</div>
)}
<div className="px-8 pb-8 pt-8">
<div className="flex items-center justify-between gap-3">
<h3 className="text-[21px] font-semibold tracking-[-0.3px] text-[#0a0a0a] dark:text-white">
{plan.name}
</h3>
{featured && !banner && (
<span className="bg-[#2337f0] px-2.5 py-1 font-mono text-[10.5px] font-medium uppercase tracking-[0.08em] text-white">
Most popular
</span>
)}
</div>
<p className="mt-2.5 min-h-[44px] text-pretty text-[14.5px] leading-[1.5] text-[#52525b] dark:text-[#a1a1a8]">
{plan.tagline}
</p>
<div className="mt-5 flex items-end gap-1.5">
{amount === null ? (
<span className="text-[34px] font-semibold leading-none tracking-[-2px] text-[#0a0a0a] dark:text-white @sm:text-[42px] @lg:text-[48px]">
Custom
</span>
) : (
<>
<PriceFigure
value={amount}
className="text-[34px] font-semibold tracking-[-2px] text-[#0a0a0a] dark:text-white @sm:text-[42px] @lg:text-[48px]"
/>
<span className="pb-1.5 text-[15px] text-[#71717a] dark:text-[#8f8f96]">
/ mo
</span>
</>
)}
</div>
<button
type="button"
className={cn(
'mt-6 h-[50px] w-full font-mono text-[14.5px]',
'transition-[transform,background-color] duration-150 active:scale-[0.98]',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#2337f0] focus-visible:ring-offset-2',
featured
? 'bg-[#2337f0] text-white hover:bg-[#1b2cd1]'
: 'bg-[#0a0a0a] text-white hover:bg-black dark:bg-[#f4f4f5] dark:text-[#0c0c0e] dark:hover:bg-white',
)}
>
{plan.cta}
</button>
</div>
<ul className="flex-1">
{plan.features.map((feature) => (
<li
key={feature}
className="flex items-center gap-3.5 border-t border-[#e4e4e7] px-8 py-[19px] dark:border-[#2b2b31]"
>
<span
aria-hidden
className="grid size-[22px] shrink-0 place-items-center rounded-full border border-[#aeb6f8] dark:border-[#3c4494]"
>
<Check
className="size-3 text-[#2337f0] dark:text-[#93a0ff]"
strokeWidth={2.4}
/>
</span>
<span className="text-[15px] text-[#27272a] dark:text-[#d4d4da]">
{feature}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</section>
);
}
export default BannerTiers;
components/spectrumui/blocks/pricing/price-figure.tsx
'use client';
import { cn } from '@/lib/utils';
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
export interface PriceFigureProps {
value: number;
currency?: string;
decimals?: number;
className?: string;
}
function Digit({ digit }: { digit: number }) {
return (
<span className="relative block h-[1em] w-[1ch] overflow-hidden">
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-[420ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
style={{ transform: `translateY(-${digit * 10}%)` }}
>
{DIGITS.map((d) => (
<span key={d} className="flex h-[1em] items-center justify-center leading-none">
{d}
</span>
))}
</span>
</span>
);
}
export function PriceFigure({ value, currency = '$', decimals = 0, className }: PriceFigureProps) {
const negative = value < 0;
const body = Math.abs(value).toFixed(decimals);
const grouped = body.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const label = `${negative ? '−' : ''}${currency}${grouped}`;
return (
<span className={cn('inline-flex leading-none tabular-nums', className)}>
<span className="sr-only">{label}</span>
<span aria-hidden className="inline-flex">
{negative && <span className="flex h-[1em] items-center leading-none">−</span>}
{currency && <span className="flex h-[1em] items-center leading-none">{currency}</span>}
{grouped.split('').map((character, index) =>
/\d/.test(character) ? (
<Digit key={`${index}-digit`} digit={Number(character)} />
) : (
<span key={`${index}-${character}`} className="flex h-[1em] items-center leading-none">
{character}
</span>
),
)}
</span>
</span>
);
}
export default PriceFigure;
components/spectrumui/blocks/pricing/types.ts
export type BillingPeriod = 'monthly' | 'annual';
export interface PlanPrice {
monthly: number;
annual: number;
}
export interface Plan {
id: string;
name: string;
tagline: string;
price: PlanPrice | null;
unit?: string;
cta: string;
featured?: boolean;
badge?: string;
inherits?: string;
features: string[];
footnote?: string;
}
export type FeatureValue = string | boolean;
export interface FeatureRow {
label: string;
hint?: string;
values: FeatureValue[];
}
export interface FeatureGroup {
label: string;
rows: FeatureRow[];
}
export interface RateTier {
from: number;
to?: number;
unitPrice: number;
}
export interface MeteredProduct {
id: string;
name: string;
description?: string;
unit: string;
unitPlural: string;
included: number;
tiers: RateTier[];
}
export interface CreditPack {
id: string;
name: string;
credits: number;
bonus: number;
price: number;
featured?: boolean;
note?: string;
}
export interface CreditRate {
id: string;
label: string;
credits: number;
per: number;
unitLabel: string;
}
export interface AddOn {
id: string;
name: string;
description: string;
price: number;
unit?: string;
perSeat?: boolean;
defaultOn?: boolean;
}
export interface ModelRate {
id: string;
name: string;
context: string;
input: number;
output: number;
cacheWrite: number;
cacheRead: number;
badge?: string;
}
export interface UsageDimension {
id: string;
label: string;
unit: string;
min: number;
max: number;
step: number;
defaultValue: number;
}
export interface ComplianceItem {
id: string;
label: string;
detail: string;
}
export interface RecommenderOption {
id: string;
label: string;
scores: Record<string, number>;
reason?: string;
}
export interface RecommenderQuestion {
id: string;
label: string;
hint?: string;
options: RecommenderOption[];
}
export interface PlanFamily {
id: string;
label: string;
description: string;
plans: Plan[];
footnote?: string;
}
components/spectrumui/blocks/pricing/reveal.ts
'use client';
import { useEffect, useRef, useState } from 'react';
export const REVEAL_KEYFRAMES = `
@keyframes su-reveal { from { opacity: 0; transform: translateY(14px) } to { opacity: 1; transform: none } }
@keyframes su-draw { from { stroke-dashoffset: 1 } to { stroke-dashoffset: 0 } }
@keyframes su-sheen { 0%, 62% { transform: translateX(-110%) } 100% { transform: translateX(110%) } }
`;
export function useReveal<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
if (typeof IntersectionObserver === 'undefined') {
const frame = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(frame);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShown(true);
observer.disconnect();
}
},
{ threshold: 0.18, rootMargin: '0px 0px -8% 0px' },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return { ref, shown };
}
export function revealClass(shown: boolean) {
return shown
? '[animation:su-reveal_480ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none'
: '';
}
export function revealDelay(shown: boolean, index: number) {
return shown ? { animationDelay: `${index * 70}ms` } : undefined;
}