Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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.ts
    export 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.ts
    import 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 };
    }
  3. 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

PropTypeDefaultDescription
titleReactNode"Deployment plans"Section title, set in mono capitals.
descriptionReactNode–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.
footnotesstring[]–Notes under the panels, separated by bullets. Write them as placeholders unless they are real commitments.
accentstring"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.
priceLabelstring"Starting from"Label above each price.
featureLabels[string, string]["Includes", "Everything in <first plan> +"]Label above each feature list.
localestring"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.