shadimage
Spectrum UI
blockserif-tierssource: ok · hit

Serif Tiers

Cream serif cards with a black featured card lit by a crimson glow.

npx shadcn add "https://ui.spectrumhq.in/r/serif-tiers.json"
free

Where it goes

feature-grid

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files4 · source cached 10/8/2026, 6:51:23 PM

components/spectrumui/blocks/pricing/serif-tiers.tsx
'use client';

import { useState } from 'react';
import { cn } from '@/lib/utils';
import { PriceFigure } from './price-figure';
import { REVEAL_KEYFRAMES, revealClass, revealDelay, useReveal } from './reveal';
import type { BillingPeriod, Plan } from './types';

export type SerifTiersVariant = 'Ember' | 'Ink';

export interface SerifTiersProps {
  plans: Plan[];
  eyebrow?: string;
  heading: string;
  subheading?: string;
  variant?: SerifTiersVariant;
  className?: string;
}

function Star({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden className={cn('size-3.5 shrink-0', className)}>
      <path
        d="M8 0c.6 4.4 3 6.8 8 8-5 1.2-7.4 3.6-8 8-.6-4.4-3-6.8-8-8 5-1.2 7.4-3.6 8-8Z"
        fill="currentColor"
      />
    </svg>
  );
}

export function SerifTiers({
  plans,
  eyebrow = 'Pricing',
  heading,
  subheading,
  variant = 'Ember',
  className,
}: SerifTiersProps) {
  const [period, setPeriod] = useState<BillingPeriod>('monthly');
  const { ref, shown } = useReveal<HTMLElement>();

  return (
    <section
      ref={ref}
      className={cn(
        '@container w-full rounded-[28px] bg-[#fbfaf7] px-5 py-12 dark:bg-[#161316] sm:px-8',
        className,
      )}
    >
      <style dangerouslySetInnerHTML={{ __html: REVEAL_KEYFRAMES }} />

      <div className={cn('flex flex-col items-center text-center', revealClass(shown))}>
        <span className="rounded-full bg-white px-4 py-2 text-[12px] font-medium uppercase tracking-[0.1em] text-[#57534e] shadow-[0_1px_2px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] dark:bg-[#211d21] dark:text-[#b6afa8] dark:shadow-[0_1px_2px_rgba(0,0,0,0.3),0_0_0_1px_rgba(255,255,255,0.06)]">
          {eyebrow}
        </span>
        <h2 className="mt-6 max-w-[20ch] text-balance [font-family:var(--font-spectral,Georgia,serif)] text-[28px] font-normal leading-[1.12] tracking-[-0.5px] text-[#1c1917] dark:text-[#f5f2ee] @sm:text-[34px] @lg:text-[46px]">
          {heading}
        </h2>
        {subheading && (
          <p className="mt-4 max-w-[44ch] text-pretty text-[16px] leading-[1.55] text-[#57534e] dark:text-[#a8a29e]">
            {subheading}
          </p>
        )}

        <div className="mt-8 inline-flex rounded-full border border-black/[0.06] bg-white p-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/[0.07] dark:bg-[#211d21]">
          {(['monthly', 'annual'] as const).map((value) => (
            <button
              key={value}
              type="button"
              aria-pressed={period === value}
              onClick={() => setPeriod(value)}
              className={cn(
                'rounded-full px-6 py-2 text-[14.5px] font-medium transition-colors duration-200',
                'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#e11d48]',
                period === value
                  ? 'bg-[#f5f2ec] text-[#1c1917] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.04)] dark:bg-[#2e2930] dark:text-[#f5f2ee] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]'
                  : 'text-[#78716c] hover:text-[#1c1917] dark:text-[#8f8a85] dark:hover:text-[#f5f2ee]',
              )}
            >
              {value === 'monthly' ? 'Monthly' : 'Yearly'}
            </button>
          ))}
        </div>
      </div>

      <div className="mt-10 grid grid-cols-1 gap-5 @3xl:grid-cols-3">
        {plans.map((plan, position) => {
          const featured = plan.featured;
          const amount = plan.price ? plan.price[period] : 0;

          return (
            <div
              key={plan.id}
              style={revealDelay(shown, position + 1)}
              className={cn(
                'relative flex flex-col overflow-hidden rounded-[24px] p-8',
                revealClass(shown),
                featured
                  ? 'bg-[#0c0a09] text-white shadow-[0_28px_60px_-28px_rgba(0,0,0,0.5)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.09),0_28px_60px_-28px_rgba(0,0,0,0.8)]'
                  : 'bg-[#f5f2ec] dark:bg-[#211d22] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]',
              )}
            >
              {featured && variant === 'Ember' && (
                <>
                  <span
                    aria-hidden
                    className="pointer-events-none absolute -bottom-40 -right-32 size-[300px] rounded-full bg-[#e11d48] opacity-70 blur-[90px]"
                  />
                  <span
                    aria-hidden
                    className="pointer-events-none absolute -bottom-24 -right-16 size-[140px] rounded-full bg-[#fb7185] opacity-60 blur-[70px]"
                  />
                </>
              )}

              <div className="relative flex flex-1 flex-col">
                <div className="flex items-center gap-3">
                  <h3
                    className={cn(
                      '[font-family:var(--font-spectral,Georgia,serif)] text-[24px] font-normal leading-none tracking-[-0.3px] @sm:text-[30px]',
                      featured ? 'text-white' : 'text-[#1c1917] dark:text-[#f5f2ee]',
                    )}
                  >
                    {plan.name}
                  </h3>
                  {plan.badge && (
                    <span className="rounded-full bg-gradient-to-r from-[#fb7185] to-[#e11d48] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-white">
                      {plan.badge}
                    </span>
                  )}
                </div>

                <p
                  className={cn(
                    'mt-3 min-h-[24px] text-pretty text-[15px] leading-[1.5]',
                    featured ? 'text-[#d6d3d1]' : 'text-[#57534e] dark:text-[#a8a29e]',
                  )}
                >
                  {plan.tagline}
                </p>

                <div className="mt-10 flex items-end gap-2">
                  <PriceFigure
                    value={amount}
                    className={cn(
                      '[font-family:var(--font-spectral,Georgia,serif)] text-[34px] font-normal tracking-[-1px] @sm:text-[40px] @lg:text-[46px]',
                      featured ? 'text-white' : 'text-[#1c1917] dark:text-[#f5f2ee]',
                    )}
                  />
                  <span
                    className={cn(
                      'pb-1.5 text-[14px]',
                      featured ? 'text-[#a8a29e]' : 'text-[#78716c] dark:text-[#8f8a85]',
                    )}
                  >
                    /month, per user
                  </span>
                </div>

                <button
                  type="button"
                  className={cn(
                    'mt-7 h-[52px] w-full rounded-full bg-white text-[15px] font-medium text-[#1c1917]',
                    'shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.03)]',
                    'transition-[transform,background-color] duration-150 hover:bg-[#faf9f6] active:scale-[0.98]',
                    'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#e11d48] focus-visible:ring-offset-2',
                    featured && 'focus-visible:ring-offset-[#0c0a09]',
                  )}
                >
                  {plan.cta}
                </button>

                <p
                  className={cn(
                    'mt-8 text-[14px]',
                    featured ? 'text-[#a8a29e]' : 'text-[#78716c] dark:text-[#8f8a85]',
                  )}
                >
                  What&rsquo;s included
                </p>
                <ul className="mt-4 space-y-3.5">
                  {plan.features.map((feature) => (
                    <li key={feature} className="flex items-center gap-3">
                      <Star
                        className={featured ? 'text-white' : 'text-[#1c1917] dark:text-[#f5f2ee]'}
                      />
                      <span
                        className={cn(
                          'text-[15px] leading-[1.45]',
                          featured ? 'text-[#e7e5e4]' : 'text-[#292524] dark:text-[#d6d0c9]',
                        )}
                      >
                        {feature}
                      </span>
                    </li>
                  ))}
                </ul>
              </div>
            </div>
          );
        })}
      </div>
    </section>
  );
}

export default SerifTiers;
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;
}

More from Spectrum UI

Browse all components in this library →