Skip to content

Pricing table

The pricing section done as a real feature comparison, not four identical bullet lists: each plan has its own checklist of included/excluded features (real Check/X icons, not text alone), a live Bill monthly/Bill annually switch that recomputes every price and reveals a "Save $N" badge on the recommended tier, and that tier promoted with a fixed dark card that stays legible in both themes.

pricing-table.tsx

Built with

How it’s built

  1. 01The Bill monthly/Bill annually switch is a real FormFieldSet type="switch", controlled (checked/onChange) — both flanking labels and the "Save $N" badge all derive from the same isAnnual state, so they can never disagree.
  2. 02Every feature row uses a real CheckIcon/XIcon colored via a wrapping <span> — the icons draw with stroke="currentColor" and take no color prop themselves, so the color has to come from an ancestor's text color.
  3. 03The featured plan's dark card uses a fixed hex (bg-[#241206]), not an oks-ui semantic token like --oks-color-primary-950 — that token is *designed* to flip to a near-white shade in dark mode, which would wash the card's white text out to near-invisible instead of staying a deliberately dark, promoted card in both themes.
  4. 04The grid needs its own @container — @2xl:grid-cols-4 has nothing to evaluate against without an ancestor establishing container-type, so it silently never applies and the plans stay stacked in one column regardless of how wide the page is.

Source

Two files · copy & paste

Styling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.

pricing-table.tsx
import { useState } from "react";
import { Button } from "oks-ui/button";
import { Card, CardBody } from "oks-ui/card";
import { Chip } from "oks-ui/chip";
import { FormFieldSet } from "oks-ui/form-field-set";
import { PageTitle } from "oks-ui/page-title";
import { CheckIcon, XIcon } from "./icons";

const plans = [
  {
    name: "Intro",
    monthly: 9,
    annual: 90,
    featured: false,
    features: [
      { label: "Upload video up to 720p", included: true },
      { label: "Attachment & post scheduling", included: false },
      { label: "Set your own rates", included: false },
      { label: "Exclusive deals", included: false },
      { label: "Advanced statistics", included: false },
    ],
  },
  {
    name: "Base",
    monthly: 15,
    annual: 150,
    featured: false,
    features: [
      { label: "Upload video in HD", included: true },
      { label: "Attachment & post scheduling", included: true },
      { label: "Set your own rates", included: false },
      { label: "Exclusive deals", included: false },
      { label: "Advanced statistics", included: false },
    ],
  },
  {
    name: "Pro",
    monthly: 18,
    annual: 176,
    featured: true,
    features: [
      { label: "Upload video in HD", included: true },
      { label: "Attachment & post scheduling", included: true },
      { label: "Set your own rates", included: true },
      { label: "Exclusive deals", included: true },
      { label: "Advanced statistics", included: false },
    ],
  },
  {
    name: "Enterprise",
    monthly: 39,
    annual: 390,
    featured: false,
    features: [
      { label: "Upload video in 4K", included: true },
      { label: "Attachment & post scheduling", included: true },
      { label: "Set your own rates", included: true },
      { label: "Exclusive deals", included: true },
      { label: "Advanced statistics", included: true },
    ],
  },
];

const PricingTable = () => {
  const [isAnnual, setIsAnnual] = useState(false);

  return (
    // @container so the @2xl:grid-cols-4 below has a container to query —
    // without an ancestor establishing container-type, a container-query
    // utility has nothing to evaluate against and silently never applies.
    <div className="@container flex flex-col items-center gap-8 px-10 pt-20 pb-40 text-center @3xl:px-12">
      <PageTitle
        as="h2"
        title="The right plan for your business"
        subtitle="Straightforward pricing that scales with your team — switch between monthly and annual billing anytime."
        classNames={{
          base: "flex-col items-center gap-2",
          title: "text-3xl font-bold tracking-tight text-ink",
          subtitle: "max-w-lg text-[0.9rem] text-ink-muted",
        }}
      />

      <div className="flex items-center gap-3">
        <span
          className={`whitespace-nowrap text-[0.9rem] font-medium ${!isAnnual ? "text-ink" : "text-ink-muted"}`}
        >
          Bill monthly
        </span>
        <FormFieldSet
          type="switch"
          name="billingPeriod"
          label={
            <span className="sr-only">Bill annually instead of monthly</span>
          }
          checked={isAnnual}
          onChange={(v: unknown) => setIsAnnual(Boolean(v))}
        />
        <span
          className={`whitespace-nowrap text-[0.9rem] font-medium ${isAnnual ? "text-ink" : "text-ink-muted"}`}
        >
          Bill annually
        </span>
      </div>

      <div className="grid w-full gap-5 @2xl:grid-cols-4">
        {plans.map((plan) => (
          <Card
            key={plan.name}
            shadow={plan.featured ? "lg" : "sm"}
            // A fixed hex, not an oks-ui semantic token — the featured
            // card's white text needs the background to stay dark in
            // BOTH themes. A token like --oks-color-primary-950 flips to a
            // near-white shade in dark mode by design, which would wash
            // the white text out to near-invisible.
            className={plan.featured ? "bg-[#241206]" : ""}
          >
            <CardBody className="flex flex-col gap-5 p-6 text-left">
              <div className="flex items-center justify-between gap-2">
                <h3
                  className={`text-[0.95rem] font-semibold ${plan.featured ? "text-white" : "text-ink"}`}
                >
                  {plan.name}
                </h3>
                {plan.featured && isAnnual ? (
                  <Chip size="sm" variant="solid" color="primary">
                    Save ${plan.monthly * 12 - plan.annual}
                  </Chip>
                ) : null}
              </div>

              <div className="flex flex-col gap-3">
                {plan.features.map((f) => (
                  <div key={f.label} className="flex items-start gap-2">
                    <span
                      className={`shrink-0 ${
                        f.included
                          ? plan.featured
                            ? "text-[color:var(--oks-color-success-400)]"
                            : "text-[color:var(--oks-color-success-700)]"
                          : plan.featured
                            ? "text-white/30"
                            : "text-ink-faint"
                      }`}
                    >
                      {f.included ? (
                        <CheckIcon size={16} />
                      ) : (
                        <XIcon size={16} />
                      )}
                    </span>
                    <span
                      className={`text-[0.85rem] ${
                        plan.featured
                          ? f.included
                            ? "text-white"
                            : "text-white/60"
                          : f.included
                            ? "text-ink-soft"
                            : "text-ink-faint"
                      }`}
                    >
                      {f.label}
                    </span>
                  </div>
                ))}
              </div>

              <p className={plan.featured ? "text-white" : "text-ink"}>
                <span className="text-[1.9rem] font-extrabold tracking-tight">
                  ${isAnnual ? plan.annual : plan.monthly}
                </span>
                <span
                  className={`text-[0.85rem] ${plan.featured ? "text-white/70" : "text-ink-muted"}`}
                >
                  /{isAnnual ? "year" : "month"}
                </span>
              </p>

              <Button
                fullWidth
                radius="full"
                color="primary"
                variant={plan.featured ? "solid" : "soft"}
              >
                {plan.featured ? "Try 1 month" : "Choose plan"}
              </Button>
            </CardBody>
          </Card>
        ))}
      </div>
    </div>
  );
};

export default PricingTable;

It imports ./icons, so save this next to it:

icons.tsx
export const CheckIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M5 13l4 4L19 7" />
    </svg>
  );
};

export const XIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
};