Marketing Sections & Logo Cloudsv0.4.0New
Blueprint Plan Duo
Two plans in a mono blueprint style with crop marks, wireframe boxes and scramble-in text.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/blueprint-plan-duo.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }lib/pricing/format.tsexport interface PriceParts { /** The whole formatted price, e.g. "$1,299" or "1.299 €". */ text: string; /** Currency symbol or code, e.g. "$", "€", "Rp". */ symbol: string; /** Everything but the symbol, e.g. "1,299". */ digits: string; /** False when the locale puts the symbol after the number. */ symbolFirst: boolean; /** Fraction digits used. */ decimals: number; } const formatters = new Map<string, Intl.NumberFormat>(); function formatter(currency: string, locale: string, decimals: number) { const key = `${locale}|${currency}|${decimals}`; let found = formatters.get(key); if (!found) { found = new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals }); formatters.set(key, found); } return found; } /** * Formats a price in major units and splits it into its symbol and digits, so the symbol can be * styled on its own. Whole amounts drop their decimals ("$199", not "$199.00"). The locale is * fixed by the caller, never read from the browser, so server and client output match. */ export function formatPrice(amount: number, currency = "USD", locale = "en-US"): PriceParts { const decimals = Number.isInteger(amount) ? 0 : 2; const parts = formatter(currency, locale, decimals).formatToParts(amount); const symbolIndex = parts.findIndex((part) => part.type === "currency"); const numberIndex = parts.findIndex((part) => part.type === "integer"); const symbol = symbolIndex === -1 ? "" : parts[symbolIndex].value; const digits = parts .filter((part) => part.type !== "currency" && part.type !== "literal") .map((part) => part.value) .join(""); return { text: parts.map((part) => part.value).join(""), symbol, digits, symbolFirst: symbolIndex === -1 || symbolIndex < numberIndex, decimals, }; }lib/pricing/types.tsimport type { ReactNode } from "react"; /** How a plan is billed. `once` is a single payment. */ export type Billing = "monthly" | "yearly" | "once"; export interface Price { /** One amount in major units (199 = $199), or one per billing period (yearly is the per-month equivalent; `once` falls back to monthly). */ amount: number | { monthly: number; yearly: number; once?: number }; /** ISO 4217 currency code, e.g. "USD". */ currency: string; /** Text after the price, e.g. "per user / month" or "One time". */ period?: string; } export interface PlanCta { /** Button label. */ label: string; /** Renders the button as a link when set. */ href?: string; } export interface Plan { /** Stable id, used for keys and selection. */ id: string; name: string; description?: string; /** Small icon shown next to the name. */ icon?: ReactNode; /** Omit for plans without a public price and set `customPrice` instead. */ price?: Price; /** Shown in place of a price, e.g. "Custom quote". */ customPrice?: string; /** Small line near the price, e.g. "Billed yearly". */ priceNote?: string; features: string[]; cta: PlanCta; /** Badge text, e.g. "Popular". */ badge?: string; /** Panel tone, for components that style plans differently. */ tone?: "accent" | "ink" | "neutral"; }lib/pricing/use-billing.ts"use client"; import { useCallback, useState } from "react"; import { formatPrice, type PriceParts } from "@/lib/pricing/format"; import type { Billing, Plan } from "@/lib/pricing/types"; export interface UseBillingOptions { /** Controlled billing period. */ billing?: Billing; /** Initial billing period when uncontrolled. @default "monthly" */ defaultBilling?: Billing; /** Called when the billing period changes. */ onBillingChange?: (billing: Billing) => void; /** Locale for formatting. @default "en-US" */ locale?: string; } export interface PlanPrice extends PriceParts { /** The amount for the current billing period, in major units. */ amount: number; currency: string; /** Props for RollingDigits (the number only, without the symbol), so the price rolls when billing changes. */ rolling: { value: number; decimals: number; locale: string }; } /** The amount a plan charges for a billing period, or null when it has no public price. */ export function planAmount(plan: Plan, billing: Billing): number | null { const amount = plan.price?.amount; if (amount === undefined) return null; if (typeof amount === "number") return amount; if (billing === "once") return amount.once ?? amount.monthly; return billing === "yearly" ? amount.yearly : amount.monthly; } /** True when a plan charges a different amount per billing period, so a billing toggle changes its price. */ export function hasBillingPrices(plan: Plan) { return typeof plan.price?.amount === "object"; } /** * Billing period state, controlled or uncontrolled, plus the price to show for a plan. * `priceFor` returns null for plans with a custom price. */ export function useBilling({ billing, defaultBilling = "monthly", onBillingChange, locale = "en-US" }: UseBillingOptions = {}) { const [internal, setInternal] = useState<Billing>(defaultBilling); const current = billing ?? internal; const setBilling = useCallback( (next: Billing) => { if (billing === undefined) setInternal(next); onBillingChange?.(next); }, [billing, onBillingChange], ); const priceFor = useCallback( (plan: Plan): PlanPrice | null => { const amount = planAmount(plan, current); if (amount === null || !plan.price) return null; const parts = formatPrice(amount, plan.price.currency, locale); return { ...parts, amount, currency: plan.price.currency, rolling: { value: amount, decimals: parts.decimals, locale } }; }, [current, locale], ); return { billing: current, setBilling, priceFor }; }Copy the source
components/ui/blueprint-plan-duo/blueprint-plan-duo.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react"; import { animate, motion, useInView, useMotionValue, useReducedMotion } from "motion/react"; import { Check, ChevronsRight } from "lucide-react"; import { TextScramble } from "@/components/ui/text-scramble/text-scramble"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; import { formatPrice } from "@/lib/pricing/format"; import type { Plan } from "@/lib/pricing/types"; import { planAmount } from "@/lib/pricing/use-billing"; export interface BlueprintPlanDuoProps extends Omit<ComponentProps<"section">, "children" | "title" | "onSelect"> { /** Section title, set in mono capitals. @default "Deployment plans" */ title?: ReactNode; /** Small letter-spaced line under the title. */ description?: ReactNode; /** Exactly two plans. Each `tone` picks its panel: "accent" (saturated) or "ink" (near-black). Without a tone, the first is accent and the second ink. */ plans: Plan[]; /** Notes under the panels, separated by bullets. Write them as placeholders unless they are real commitments. */ footnotes?: string[]; /** Color of the accent panel, as any CSS color. White text sits on it, so keep it at 4.5:1 or better. @default "var(--primary)" */ accent?: string; /** Label above each price. @default "Starting from" */ priceLabel?: string; /** Label above each feature list. @default ["Includes", "Everything in <first plan> +"] */ featureLabels?: [string, string]; /** Locale for prices. @default "en-US" */ locale?: string; /** Called when a plan's button is pressed (when its cta has no href). */ onSelect?: (plan: Plan) => void; } /** Corners and edges of a unit cube. */ const CORNERS = [ [-1, -1, -1], [1, -1, -1], [1, -1, 1], [-1, -1, 1], [-1, 1, -1], [1, 1, -1], [1, 1, 1], [-1, 1, 1], ] as const; const EDGES = [ [0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7], ] as const; const TILT = Math.PI / 6; /** The cube turned by `angle` around its vertical axis and projected from above, as an SVG path in a 100×100 box. */ function boxPath(angle: number) { const cos = Math.cos(angle); const sin = Math.sin(angle); const points = CORNERS.map(([x, y, z]) => { const rx = x * cos - z * sin; const rz = x * sin + z * cos; return [50 + rx * 30, 50 - y * 30 * Math.cos(TILT) + rz * 30 * Math.sin(TILT)]; }); return EDGES.map(([a, b]) => `M${points[a][0].toFixed(2)} ${points[a][1].toFixed(2)}L${points[b][0].toFixed(2)} ${points[b][1].toFixed(2)}`).join(""); } const REST_ANGLE = Math.PI / 4; const REST_PATH = boxPath(REST_ANGLE); interface PanelProps { plan: Plan; tone: "accent" | "ink"; index: number; revealed: boolean; priceLabel: string; featureLabel: string; locale: string; onSelect?: (plan: Plan) => void; } function Panel({ plan, tone, index, revealed, priceLabel, featureLabel, locale, onSelect }: PanelProps) { const reduceMotion = useReducedMotion(); const headingId = useId(); const boxRef = useRef<SVGPathElement>(null); const angle = useMotionValue(REST_ANGLE); const [sweep, setSweep] = useState(0); // The wireframe is re-projected as it turns; the path is written straight to the DOM. useEffect(() => angle.on("change", (value) => boxRef.current?.setAttribute("d", boxPath(value))), [angle]); function onEnter(pointerType: string) { if (reduceMotion || pointerType === "touch") return; // A quarter turn lands the cube on an identical pose, so every hover starts from rest. angle.jump(REST_ANGLE); animate(angle, REST_ANGLE + Math.PI / 2, { duration: 1.6, ease: ease.inOut }); setSweep((n) => n + 1); } const amount = planAmount(plan, "monthly"); const price = amount !== null && plan.price ? formatPrice(amount, plan.price.currency, locale) : null; const priceText = price ? `${price.text}${plan.price?.period ?? ""}` : (plan.customPrice ?? ""); const draw = { strokeDasharray: 1, strokeDashoffset: revealed ? 0 : 1 } as const; const buttonClass = cn( "group/cta flex h-12 w-full cursor-pointer items-center justify-between border border-white bg-white px-4 text-[13px] font-semibold tracking-[0.04em] uppercase outline-none transition-colors duration-200", "hover:bg-transparent hover:text-white focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-(--panel)", tone === "accent" ? "text-(--duo-accent)" : "text-[#0b0b0d]", ); const buttonInner = ( <> <span>{plan.cta.label}</span> <ChevronsRight aria-hidden="true" className="size-4 transition-transform duration-200 group-hover/cta:translate-x-1 motion-reduce:transition-none motion-reduce:group-hover/cta:translate-x-0" strokeWidth={2.25} /> </> ); return ( <article aria-labelledby={headingId} onPointerEnter={(event) => onEnter(event.pointerType)} style={{ "--panel": tone === "accent" ? "var(--duo-accent)" : "#0b0b0d" } as CSSProperties} className={cn( "relative isolate flex flex-col overflow-hidden p-5 text-white @[36rem]:p-6", tone === "accent" ? "bg-(--duo-accent)" : "bg-[#0b0b0d] bg-[radial-gradient(90%_70%_at_100%_0%,rgb(255_255_255/0.14),transparent_65%)]", )} > {/* A faint scanline sweeps down once per hover. */} {sweep > 0 ? ( <motion.div key={sweep} aria-hidden="true" initial={{ y: "-100%" }} animate={{ y: "100%" }} transition={{ duration: 0.9, ease: "linear" }} className="pointer-events-none absolute inset-0 -z-10 bg-[linear-gradient(to_bottom,transparent_88%,rgb(255_255_255/0.14))]" /> ) : null} <svg aria-hidden="true" viewBox="0 0 100 100" className="pointer-events-none absolute -top-2 -right-2 -z-10 size-36 opacity-30 @[36rem]:size-44" fill="none"> <path ref={boxRef} d={REST_PATH} pathLength={1} stroke="currentColor" strokeWidth={0.7} strokeLinecap="round" style={{ ...draw, transition: `stroke-dashoffset ${duration.slow * 2}s cubic-bezier(${ease.out.join(",")}) ${0.15 + index * 0.1}s` }} className="motion-reduce:transition-none!" /> </svg> <span aria-hidden="true" className="grid size-10 place-items-center border border-white/70 [&_svg]:size-5"> {plan.icon} </span> <h3 id={headingId} className="mt-5 text-lg font-semibold tracking-[0.04em] uppercase"> {plan.name} </h3> {plan.description ? <p className="mt-1.5 max-w-[30ch] text-xs leading-5 tracking-[0.04em] text-white/90 uppercase">{plan.description}</p> : null} <hr className="my-5 border-white/25" /> <p className="text-[11px] tracking-[0.12em] text-white/90 uppercase">{price ? priceLabel : (plan.priceNote ?? priceLabel)}</p> <TextScramble as="p" text={priceText.toUpperCase()} trigger="inView" duration={700} characters="0123456789$#/%" className="mt-2 text-4xl leading-none font-bold tracking-tight @[36rem]:text-5xl" /> <hr className="my-5 border-white/25" /> <p className="text-[11px] tracking-[0.12em] text-white/90 uppercase">{featureLabel}</p> <ul className="mt-3 flex-1 space-y-2.5 text-xs tracking-[0.04em] uppercase"> {plan.features.map((feature, row) => ( <li key={feature} className="flex items-start gap-2.5 leading-5"> <Check aria-hidden="true" className="mt-0.5 size-4 shrink-0" strokeWidth={2.5} /> {/* Rows resolve one after another: each takes a little longer than the one above. */} <TextScramble text={feature.toUpperCase()} trigger="inView" duration={500 + row * 160} /> </li> ))} </ul> <div className="mt-6"> {plan.cta.href ? ( <a href={plan.cta.href} className={buttonClass}> {buttonInner} </a> ) : ( <button type="button" onClick={() => onSelect?.(plan)} className={buttonClass}> {buttonInner} </button> )} </div> </article> ); } /** An L-shaped crop mark; `flip` mirrors it into each corner. */ function CropMark({ revealed, className }: { revealed: boolean; className: string }) { return ( <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className={cn("pointer-events-none absolute size-4 text-foreground", className)}> <path d="M15.5 0.5H0.5V15.5" pathLength={1} stroke="currentColor" strokeWidth={1} style={{ strokeDasharray: 1, strokeDashoffset: revealed ? 0 : 1, transition: `stroke-dashoffset 0.3s cubic-bezier(${ease.out.join(",")})` }} className="motion-reduce:transition-none!" /> </svg> ); } export function BlueprintPlanDuo({ title = "Deployment plans", description, plans, footnotes, accent = "var(--primary)", priceLabel = "Starting from", featureLabels, locale = "en-US", onSelect, className, style, ...props }: BlueprintPlanDuoProps) { const ref = useRef<HTMLElement>(null); const revealed = useInView(ref, { once: true, amount: 0.25 }); const pair = plans.slice(0, 2); const labels = featureLabels ?? ["Includes", `Everything in ${pair[0]?.name ?? "the first plan"} +`]; return ( <section ref={ref} style={{ "--duo-accent": accent, ...style } as CSSProperties} className={cn("@container w-full p-2 font-mono", className)} {...props} > <div className="relative border border-foreground/15 p-4 @[36rem]:p-6"> {/* Crop marks sit just outside the frame and draw in from the corners. */} <CropMark revealed={revealed} className="-top-2 -left-2" /> <CropMark revealed={revealed} className="-top-2 -right-2 -scale-x-100" /> <CropMark revealed={revealed} className="-bottom-2 -left-2 -scale-y-100" /> <CropMark revealed={revealed} className="-right-2 -bottom-2 -scale-100" /> <header> <h2 className="text-xl font-semibold tracking-[0.04em] text-foreground uppercase @[36rem]:text-2xl">{title}</h2> {description ? <p className="mt-2 text-[11px] leading-5 tracking-[0.14em] text-muted-foreground uppercase">{description}</p> : null} </header> <div className="mt-6 grid grid-cols-1 gap-2 @[36rem]:grid-cols-2"> {pair.map((plan, index) => ( <Panel key={plan.id} plan={plan} tone={plan.tone === "ink" || plan.tone === "accent" ? plan.tone : index === 0 ? "accent" : "ink"} index={index} revealed={revealed} priceLabel={priceLabel} featureLabel={labels[index]} locale={locale} onSelect={onSelect} /> ))} </div> {footnotes?.length ? ( <ul className="mt-5 flex flex-wrap gap-x-3 gap-y-1 text-[10.5px] tracking-[0.12em] text-muted-foreground uppercase"> {footnotes.map((note, index) => ( <li key={note} className="flex items-center gap-3"> {index > 0 ? <span aria-hidden="true">•</span> : null} {note} </li> ))} </ul> ) : null} </div> </section> ); }
Usage
import { Building2, Rocket } from "lucide-react";
import { BlueprintPlanDuo } from "@/components/ui/blueprint-plan-duo/blueprint-plan-duo";
import type { Plan } from "@/lib/pricing/types";
const plans: Plan[] = [
{ id: "launch", name: "Launch", tone: "accent", icon: <Rocket />, price: { amount: 499, currency: "USD", period: "/mo" }, features: ["3 environments"], cta: { label: "Start a launch" } },
{ id: "scale", name: "Scale", tone: "ink", icon: <Building2 />, customPrice: "Custom pricing", features: ["Dedicated regions"], cta: { label: "Book a walkthrough" } },
];
export function Plans() {
return <BlueprintPlanDuo title="Deployment plans" plans={plans} footnotes={["Cancel anytime"]} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | "Deployment plans" | Section title, set in mono capitals. |
description | ReactNode | – | Small letter-spaced line under the title. |
plans* | Plan[] | – | Exactly two plans. Each `tone` picks its panel: "accent" (saturated) or "ink" (near-black). Without a tone, the first is accent and the second ink. |
footnotes | string[] | – | Notes under the panels, separated by bullets. Write them as placeholders unless they are real commitments. |
accent | string | "var(--primary)" | Color of the accent panel, as any CSS color. White text sits on it, so keep it at 4.5:1 or better. |
priceLabel | string | "Starting from" | Label above each price. |
featureLabels | [string, string] | ["Includes", "Everything in <first plan> +"] | Label above each feature list. |
locale | string | "en-US" | Locale for prices. |
onSelect | (plan: Plan) => void | – | Called when a plan's button is pressed (when its cta has no href). |
Also accepts every native <section> attribute, including ref and className.
- Pass exactly two plans; extra ones are ignored. tone picks each panel, and without it the first is accent and the second ink.
- On first scroll into view the crop marks and wireframe boxes draw in and the price and feature rows scramble in one after another (Text Scramble).
- Hovering a panel turns its wireframe box a quarter turn (the cube is re-projected in JS and written straight to the path) and sweeps one scanline down. Touch skips both.
- Everything is set in the mono font in capitals. Plans with a price per billing period show the monthly amount; this section has no billing toggle.
- The panels stack below 36rem of the component's own width. The crop marks stay on the outer frame.
Accessibility
- Each panel is an article labelled by its heading, features are a list and every call to action is a real link or button with a white focus ring.
- The scramble runs on aria-hidden copies. The real price and feature text is in the DOM from the first render, so screen readers never hear scrambled characters.
- Crop marks, wireframe boxes and the scanline are decorative and hidden from assistive tech.
- White text sits on both panels at 4.5:1 or better with the default accent; check your own accent against white.
- With reduced motion enabled, nothing scrambles, draws, rotates or sweeps: the section renders finished.