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/></>;
}

Props & types

Scroll horizontally for long lines
{ className?: string }

Source

View 5 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 { cn } from "../ui/utils";
import { Button } from "../ui/button";
import { Progress } from "../ui/progress";
import { useDemoState } from "./demo-state";
export function BillingSettings({ className }: { className?: string }) {
  const [plan, setPlan] = useDemoState("plan", "Team");
  const [choice, setChoice] = React.useState(plan);
  const [status, setStatus] = React.useState("");
  React.useEffect(() => setChoice(plan), [plan]);
  return (
    <section className={cn("grid max-w-2xl gap-6", className)}>
      <div>
        <h2 className="text-lg font-semibold">Plan & billing</h2>
        <p className="mt-1 text-sm text-muted-foreground">
          Choose the space your team needs.
        </p>
      </div>
      <fieldset>
        <legend className="sr-only">Choose plan</legend>
        <div className="grid gap-3 sm:grid-cols-3">
          {[
            { 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" },
          ].map((p) => (
            <label
              key={p.name}
              className={cn(
                "relative cursor-pointer rounded-xl border p-5",
                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>
            </label>
          ))}
        </div>
      </fieldset>
      <div className="rounded-xl border border-border p-5">
        <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>
      </div>
      <div className="flex flex-wrap items-center justify-between gap-4 border-t border-border pt-5">
        <span className="flex items-center gap-2 text-xs text-muted-foreground">
          <CreditCard size={16} />
          Preview pricing · no payment connected
        </span>
        <Button
          onClick={() => {
            setPlan(choice);
            setStatus(`${choice} selected for this demo. No charge was made.`);
          }}
        >
          Update demo plan
        </Button>
      </div>
      {status && (
        <p role="status" className="text-sm">
          {status}
        </p>
      )}
    </section>
  );
}

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.ButtonHTMLAttributes<HTMLButtonElement> & {
  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({
  value,
  label = "Progress",
  className,
  showLabel = true,
}: {
  value: number;
  label?: string;
  className?: string;
  showLabel?: boolean;
}) {
  const v = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
  return (
    <div className={cn("grid w-full min-w-0 gap-2", className)}>
      {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/blocks/demo-state.ts

Scroll horizontally for long lines
"use client";
import { useState, useEffect, useCallback } from "react";
export function useDemoState<T>(key: string, initial: T) {
  const [state, setState] = useState(initial);
  const [loaded, setLoaded] = useState(false);
  useEffect(() => {
    try {
      const raw = localStorage.getItem("jez-demo:" + key);
      if (raw) setState(JSON.parse(raw));
    } catch {}
    setLoaded(true);
  }, [key]);
  useEffect(() => {
    if (loaded)
      try {
        localStorage.setItem("jez-demo:" + key, JSON.stringify(state));
      } catch {}
  }, [key, state, loaded]);
  const reset = useCallback(() => setState(initial), [initial]);
  return [state, setState, reset] 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 →