Skip to content
← Blocks

Billing Settings

A working billing settings with illustrative data and frontend interactions.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/billing-settings.json

Use it

Scroll horizontally for long lines
"use client";
import { BillingSettings } from "@/components/jez-ui/blocks/billing-settings";

export default function Example() {
  return (
    <>
      <BillingSettings />
    </>
  );
}

Compose your own

These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.

Scroll horizontally for long lines
"use client";
import {
  BillingSettings,
  BillingSettingsError,
  BillingSettingsIntro,
  BillingSettingsPlans,
  BillingSettingsUsage,
  BillingSettingsActions,
  BillingSettingsStatus,
} from "@/components/jez-ui/blocks/billing-settings";

export default function Example() {
  return (
    <BillingSettings>
      <BillingSettingsError />
      <BillingSettingsIntro />
      <BillingSettingsPlans />
      <BillingSettingsUsage />
      <BillingSettingsActions />
      <BillingSettingsStatus />
    </BillingSettings>
  );
}
Scroll horizontally for long lines
import { BillingSettings, BillingSettingsTitle, BillingSettingsContent, BillingSettingsHeader, BillingSettingsItem, BillingSettingsError, BillingSettingsIntro, BillingSettingsPlans, BillingSettingsUsage, BillingSettingsActions, BillingSettingsStatus } from "@/components/jez-ui/blocks/billing-settings";
Read the composition guide →

Props & types

Scroll horizontally for long lines
export type BillingSettingsOptions = {
  className?: string;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  plans?: typeof BillingSettingsDefaultPlans;
  heading?: React.ReactNode;
  onSave?: (value: string) => void | Promise<void>;
};

export type BillingSettingsProps = Omit<
  React.ComponentProps<"section">,
  keyof BillingSettingsOptions
> &
  BillingSettingsOptions;

BillingSettingsProps

Source

View 6 source files

registry/blocks/billing-settings.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Check, CreditCard } from "lucide-react";
import { useAsyncAction } from "../ui/use-async-action";
import { cn } from "../ui/utils";
import { Button } from "../ui/button";
import { Progress } from "../ui/progress";
import { useControllable } from "../ui/use-controllable";
export type BillingSettingsOptions = {
  className?: string;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  plans?: typeof BillingSettingsDefaultPlans;
  heading?: React.ReactNode;
  onSave?: (value: string) => void | Promise<void>;
};
export type BillingSettingsProps = Omit<
  React.ComponentProps<"section">,
  keyof BillingSettingsOptions
> &
  BillingSettingsOptions;
const BillingSettingsDefaultPlans = [
  { name: "Personal", price: 0, detail: "For your own projects" },
  { name: "Team", price: 12, detail: "For teams building together" },
  { name: "Studio", price: 24, detail: "For a growing practice" },
];
function useBillingSettingsModel({
  plans = BillingSettingsDefaultPlans,
  heading = "Plan & billing",
  className,
  value: controlledValue,
  defaultValue = "Team",
  onValueChange,
  onSave,
  children,
  ...rootProps
}: BillingSettingsProps) {
  const action = useAsyncAction();
  const [plan, setPlan] = useControllable<string>(
    controlledValue,
    defaultValue,
    onValueChange,
  );
  const [choice, setChoice] = React.useState(plan);
  const [status, setStatus] = React.useState("");
  React.useEffect(() => setChoice(plan), [plan]);
  return {
    plans,
    heading,
    className,
    controlledValue,
    defaultValue,
    onValueChange,
    onSave,
    children,
    rootProps,
    action,
    plan,
    setPlan,
    choice,
    setChoice,
    status,
    setStatus,
  };
}
const BillingSettingsCompositionContext = React.createContext<ReturnType<
  typeof useBillingSettingsModel
> | null>(null);
function useBillingSettingsComposition() {
  const context = React.useContext(BillingSettingsCompositionContext);
  if (!context)
    throw new Error("BillingSettings parts must be inside BillingSettings.");
  return context;
}
export function BillingSettings(props: BillingSettingsProps) {
  const model = useBillingSettingsModel(props);
  const { className, rootProps, children } = model;
  return (
    <BillingSettingsCompositionContext.Provider value={model}>
      <section {...rootProps} className={cn("grid max-w-2xl gap-6", className)}>
        {children !== undefined ? (
          children
        ) : (
          <>
            <BillingSettingsError />
            <BillingSettingsIntro />
            <BillingSettingsPlans />
            <BillingSettingsUsage />
            <BillingSettingsActions />
            <BillingSettingsStatus />
          </>
        )}
      </section>
    </BillingSettingsCompositionContext.Provider>
  );
}

export function BillingSettingsTitle({
  className,
  ...props
}: React.ComponentProps<"h2">) {
  return (
    <h2
      data-slot="billing-settings-title"
      className={cn("text-lg font-semibold", className)}
      {...props}
    />
  );
}
export function BillingSettingsContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="billing-settings-content"
      className={cn("rounded-xl border border-border p-5", className)}
      {...props}
    />
  );
}
export function BillingSettingsHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="billing-settings-header"
      className={cn(
        "flex flex-wrap items-center justify-between gap-4 border-t border-border pt-5",
        className,
      )}
      {...props}
    />
  );
}

export function BillingSettingsItem({
  className,
  ...props
}: React.ComponentProps<"label">) {
  return (
    <label
      data-slot="billing-settings-item"
      className={cn("relative cursor-pointer rounded-xl border p-5", className)}
      {...props}
    />
  );
}

export function BillingSettingsError({ children }: React.PropsWithChildren) {
  const { action } = useBillingSettingsComposition();
  return children === undefined
    ? action.error && <p role="alert">{action.error}</p>
    : children;
}
export function BillingSettingsIntro({
  children,
  ...props
}: Partial<React.ComponentProps<"div">> & { children?: React.ReactNode }) {
  const { heading } = useBillingSettingsComposition();
  return (
    <div {...props}>
      {children === undefined ? (
        <>
          <BillingSettingsTitle>{heading}</BillingSettingsTitle>
          <p className="mt-1 text-sm text-muted-foreground">
            Choose the space your team needs.
          </p>
        </>
      ) : (
        children
      )}
    </div>
  );
}
export function BillingSettingsPlans({
  children,
  ...props
}: Partial<React.ComponentProps<"fieldset">> & { children?: React.ReactNode }) {
  const { plans, choice, setChoice } = useBillingSettingsComposition();
  return (
    <fieldset {...props}>
      {children === undefined ? (
        <>
          <legend className="sr-only">Choose plan</legend>
          <div className="grid gap-3 sm:grid-cols-3">
            {plans.map((p) => (
              <BillingSettingsItem
                key={p.name}
                className={cn(
                  choice === p.name
                    ? "border-primary bg-primary/4"
                    : "border-border",
                )}
              >
                <input
                  type="radio"
                  name="plan"
                  className="sr-only peer"
                  checked={choice === p.name}
                  onChange={() => setChoice(p.name)}
                />
                <span className="block text-sm font-medium peer-focus-visible:underline">
                  {p.name}
                </span>
                {choice === p.name && (
                  <Check
                    size={15}
                    className="absolute right-4 top-5 text-primary"
                  />
                )}
                <span className="mt-5 block font-display text-3xl">
                  £{p.price}
                  <span className="ml-1 font-sans text-xs text-muted-foreground">
                    / month
                  </span>
                </span>
                <span className="mt-3 block text-xs leading-relaxed text-muted-foreground">
                  {p.detail}
                </span>
              </BillingSettingsItem>
            ))}
          </div>
        </>
      ) : (
        children
      )}
    </fieldset>
  );
}
export function BillingSettingsUsage({
  children,
  ...props
}: Partial<React.ComponentProps<typeof BillingSettingsContent>> & {
  children?: React.ReactNode;
}) {
  const { plan } = useBillingSettingsComposition();
  return (
    <BillingSettingsContent {...props}>
      {children === undefined ? (
        <>
          <div className="mb-4 flex items-center justify-between text-sm">
            <span className="font-medium">Workspace storage</span>
            <span className="text-muted-foreground">2.4 GB of 10 GB</span>
          </div>
          <Progress value={24} label="Storage used" showLabel={false} />
          <p className="mt-3 text-xs text-muted-foreground">
            Illustrative usage for the {plan} plan.
          </p>
        </>
      ) : (
        children
      )}
    </BillingSettingsContent>
  );
}
export function BillingSettingsActions({
  children,
  ...props
}: Partial<React.ComponentProps<typeof BillingSettingsHeader>> & {
  children?: React.ReactNode;
}) {
  const { onSave, action, setPlan, choice, setStatus } =
    useBillingSettingsComposition();
  return (
    <BillingSettingsHeader {...props}>
      {children === undefined ? (
        <>
          <span className="flex items-center gap-2 text-xs text-muted-foreground">
            <CreditCard size={16} />
            Preview pricing · no payment connected
          </span>
          <Button
            disabled={action.pending}
            loading={action.pending}
            onClick={() => {
              void action.run(async () => {
                await onSave?.(choice);
                setPlan(choice);
                setStatus(
                  onSave
                    ? `${choice} selected.`
                    : `${choice} selected locally. No charge was made.`,
                );
              });
            }}
          >
            Update plan
          </Button>
        </>
      ) : (
        children
      )}
    </BillingSettingsHeader>
  );
}
export function BillingSettingsStatus({ children }: React.PropsWithChildren) {
  const { status } = useBillingSettingsComposition();
  return children === undefined
    ? status && (
        <p role="status" className="text-sm">
          {status}
        </p>
      )
    : children;
}

registry/ui/use-async-action.ts

Scroll horizontally for long lines
"use client";
import * as React from "react";
/** Keeps duplicate submissions out and reports failures without committing local success. */
export function useAsyncAction() {
  const [pending, setPending] = React.useState(false);
  const [error, setError] = React.useState<string>();
  const busy = React.useRef(false);
  async function run(action: () => void | Promise<void>) {
    if (busy.current) return false;
    busy.current = true;
    setPending(true);
    setError(undefined);
    try {
      await action();
      return true;
    } catch (error) {
      setError(
        error instanceof Error
          ? error.message
          : "The action failed. Please try again.",
      );
      return false;
    } finally {
      busy.current = false;
      setPending(false);
    }
  }
  return { run, pending, error };
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

registry/ui/button.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
import { LoaderCircle } from "lucide-react";
import { cn } from "./utils";
export type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "secondary" | "outline" | "ghost" | "danger";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
  asChild?: boolean;
};
export function Button({
  variant = "primary",
  size = "md",
  loading,
  asChild,
  className,
  children,
  disabled,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp
      type={asChild ? undefined : "button"}
      disabled={asChild ? undefined : disabled || loading}
      aria-disabled={disabled || loading || undefined}
      aria-busy={loading || undefined}
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium leading-none transition-[background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[.98] disabled:pointer-events-none disabled:opacity-45 aria-disabled:pointer-events-none [&_svg]:shrink-0",
        {
          "bg-primary text-primary-foreground shadow-[inset_0_1px_0_#ffffff26,0_1px_2px_#00000012] hover:bg-primary/90":
            variant === "primary",
          "border-border/60 bg-muted text-foreground hover:bg-muted/70":
            variant === "secondary",
          "border-border bg-background text-foreground shadow-sm hover:bg-muted/60":
            variant === "outline",
          "text-muted-foreground hover:bg-muted hover:text-foreground":
            variant === "ghost",
          "bg-danger text-danger-foreground hover:bg-danger/90":
            variant === "danger",
        },
        {
          "h-8 px-3 text-xs": size === "sm",
          "h-10 px-4": size === "md",
          "h-12 px-6": size === "lg",
        },
        className,
      )}
      {...props}
    >
      {asChild ? (
        children
      ) : (
        <>
          <span
            className={cn(
              "inline-flex min-w-0 flex-1 items-center justify-center gap-2",
              loading && "invisible",
            )}
          >
            {children}
          </span>
          {loading && (
            <LoaderCircle
              aria-hidden="true"
              className="absolute size-4 animate-spin motion-reduce:animate-none"
            />
          )}
        </>
      )}
    </Comp>
  );
}

registry/ui/progress.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Progress({
  children,
  value,
  label = "Progress",
  className,
  showLabel = true,
  ...rootProps
}: Omit<
  React.ComponentProps<"div">,
  keyof {
    value: number;
    label?: string;
    className?: string;
    showLabel?: boolean;
  }
> & {
  value: number;
  label?: string;
  className?: string;
  showLabel?: boolean;
}) {
  const v = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
  return (
    <div {...rootProps} className={cn("grid w-full min-w-0 gap-2", className)}>
      {children !== undefined ? (
        children
      ) : (
        <>
          {showLabel && (
            <div className="flex justify-between text-sm">
              <span>{label}</span>
              <span className="tabular-nums">{Math.round(v)}%</span>
            </div>
          )}
          <div
            role="progressbar"
            aria-label={label}
            aria-valuenow={v}
            aria-valuemin={0}
            aria-valuemax={100}
            className="h-2 overflow-hidden rounded-full bg-muted"
          >
            <div
              className="h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-out motion-reduce:transition-none"
              style={{ transform: `scaleX(${v / 100})` }}
            />
          </div>
        </>
      )}
    </div>
  );
}

registry/ui/use-controllable.ts

Scroll horizontally for long lines
"use client";
import { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
  value: T | undefined,
  initial: T,
  onChange?: (value: T) => void,
) {
  const [local, setLocal] = useState(initial);
  const current = useRef(value === undefined ? local : value);
  current.current = value === undefined ? local : value;
  return [
    current.current,
    (action: SetStateAction<T>) => {
      const next =
        typeof action === "function"
          ? (action as (previous: T) => T)(current.current)
          : action;
      current.current = next;
      if (value === undefined) setLocal(next);
      onChange?.(next);
    },
  ] as const;
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0 · radix-ui@1.6.7

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →