ns-ui
Brine Float
A pricing section built as one shallow tank: an SVG water line runs behind the whole tier row and each card is a hydrometer float riding it, heavier (cheaper) tiers sitting lower and the recommended tier riding highest, with the billing toggle changing the brine's density so a price change is felt as buoyancy.
Use when a pricing section where the whole row shares one physical medium and the billing toggle acts on that medium (every card re-settles to a new depth at once); pick pricing-scale instead when the comparison is between exactly two tiers on a literal balance beam with feature checkmarks adding weight, or a single-value gauge component when only one number (quota, threshold) needs a physical reading rather than a whole row of cards.
Install
npx shadcn add https://design.helpmarq.com /r/brine-float.jsonSource
registry/core/brine-float/component.tsx"use client";
import { useEffect, useId, useMemo, useState } from "react";
// ---------------------------------------------------------------------------
// BrineFloat — a pricing section built as one shallow tank. A single SVG
// water line runs behind the whole tier row (a low-amplitude two-segment
// sine path that slowly morphs between itself and its mirror over 10s); each
// tier card is a hydrometer float riding in it. Buoyancy, not the beam or
// lever metaphor: every card gets its own fixed "weight class" (cheaper
// tiers are heavier and sit lower, the recommended tier is deliberately the
// lightest so it rides highest regardless of price rank) and the billing
// toggle changes the brine's density — every float eases to a new
// equilibrium on a spring curve with one visible overshoot (~600ms
// back-out), so a price change is FELT as buoyancy rather than read as a
// number swap. Two nested transforms per card: an outer wrapper carries the
// equilibrium position (transitions on billing change and on hover, both
// through the same spring easing) and an inner wrapper carries a small
// continuous idle bob (±3px, per-card duration/delay so phases never lock).
// Both are transform-only and pure CSS — no rAF loop, no canvas. The billing
// switch is a native role=switch; its aria-checked announcement plus the
// visible price text carries the change, so nothing needs a live region.
// ---------------------------------------------------------------------------
export type BrineTier = {
name: string;
tagline: string;
/** USD per month at monthly billing */
monthlyPrice: number;
cta: string;
features: string[];
/**
* Buoyancy weight class, 0..1. 1 = heaviest (sinks lowest), 0 = lightest
* (rides highest). Cheaper tiers are conventionally heavier; the
* recommended tier is given the lowest weight of the three so it always
* rides above the others regardless of where its price falls.
*/
weight: number;
recommended?: boolean;
};
const DEFAULT_TIERS: BrineTier[] = [
{
name: "Starter",
tagline: "Dip a toe in",
monthlyPrice: 9,
cta: "Start free",
weight: 1,
features: ["1 workspace", "Community support", "3 day history"],
},
{
name: "Crew",
tagline: "For teams that ship weekly",
monthlyPrice: 29,
cta: "Start Crew",
weight: 0.12,
recommended: true,
features: ["Unlimited workspaces", "Priority support", "90 day history"],
},
{
name: "Fleet",
tagline: "Every seat, every project",
monthlyPrice: 79,
cta: "Talk to sales",
weight: 0.55,
features: ["Unlimited seats", "SSO + audit log", "Unlimited history"],
},
];
// -- tank geometry (px) ------------------------------------------------------
const SINK_MIN = -14; // px — most a float can ride above the baseline
const SINK_SPAN = 40; // px — full weight-class travel at monthly density
const DENSITY: Record<"monthly" | "annual", number> = {
monthly: 1,
annual: 0.55, // annual = cheaper effective price = thinner brine = everything lighter
};
function equilibriumPx(weight: number, billing: "monthly" | "annual") {
return SINK_MIN + weight * DENSITY[billing] * SINK_SPAN;
}
// -- SVG water line: a two-segment cubic-bezier sine approximation, morphed
// between itself and its vertical mirror over a 10s loop ------------------
const WAVE_W = 100;
const WAVE_MID = 12;
const WAVE_AMP = 3.2;
function wavePath(amp: number) {
const w = WAVE_W;
const m = WAVE_MID;
return `M0,${m} C${w * 0.25},${m - amp} ${w * 0.25},${m - amp} ${w * 0.5},${m} C${w * 0.75},${m + amp} ${w * 0.75},${m + amp} ${w},${m}`;
}
const WAVE_A = wavePath(WAVE_AMP);
const WAVE_B = wavePath(-WAVE_AMP);
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
// per-card bob timing — fixed, distinct values so the three phases never lock
const BOB = [
{ dur: 4.4, delay: 0 },
{ dur: 5.6, delay: 0.9 },
{ dur: 5.1, delay: 1.7 },
];
export function BrineFloat({
tiers = DEFAULT_TIERS,
annualMultiplier = 0.8,
className = "",
}: {
tiers?: BrineTier[];
/** annual billing price multiplier applied to the monthly-equivalent shown, 0.8 = 20% off */
annualMultiplier?: number;
className?: string;
}) {
const [billing, setBilling] = useState<"monthly" | "annual">("monthly");
const reduced = useReducedMotion();
const switchId = useId();
const annual = billing === "annual";
const priceOf = (t: BrineTier) =>
annual ? Math.round(t.monthlyPrice * annualMultiplier) : t.monthlyPrice;
const wave = useMemo(
() => (reduced ? WAVE_A : `${WAVE_A};${WAVE_B};${WAVE_A}`),
[reduced]
);
return (
<div className={`relative w-full ${className}`}>
<style>{CSS}</style>
<div className="mb-10 flex items-center justify-center gap-3">
<span
id={`${switchId}-monthly`}
className={`font-mono text-xs uppercase tracking-widest transition-colors duration-150 ${
!annual ? "text-foreground" : "text-ns-muted"
}`}
>
Monthly
</span>
<button
type="button"
role="switch"
aria-checked={annual}
aria-labelledby={`${switchId}-monthly ${switchId}-annual`}
onClick={() => setBilling(annual ? "monthly" : "annual")}
className="bf-track relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-border bg-background transition-colors duration-150 hover:border-foreground/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
<span
aria-hidden="true"
className="bf-thumb block h-4 w-4 rounded-full bg-foreground"
style={{ transform: annual ? "translateX(21px)" : "translateX(3px)" }}
/>
</button>
<span
id={`${switchId}-annual`}
className={`font-mono text-xs uppercase tracking-widest transition-colors duration-150 ${
annual ? "text-foreground" : "text-ns-muted"
}`}
>
Annual <span className="text-foreground">−{Math.round((1 - annualMultiplier) * 100)}%</span>
</span>
</div>
<div className="relative pt-6 pb-12">
<svg
aria-hidden="true"
preserveAspectRatio="none"
viewBox={`0 0 ${WAVE_W} 24`}
className="pointer-events-none absolute inset-x-0 top-1/2 h-12 w-full -translate-y-1/2 text-border"
>
<path d={WAVE_A} fill="none" stroke="currentColor" strokeWidth={0.6} vectorEffect="non-scaling-stroke">
{!reduced ? (
<animate
attributeName="d"
values={wave}
dur="10s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0;0.5;1"
keySplines="0.42 0 0.58 1;0.42 0 0.58 1"
/>
) : null}
</path>
</svg>
{/*
The vertical padding has to live on THIS element, not on the parent.
`overflow-x-auto` sets overflow-x to a non-visible value, and per the
CSS overflow spec that forces the computed overflow-y from `visible`
to `auto` — so this scroll container clips vertically too. The cards
are moved by transform (equilibrium -14..+26px, plus a +/-3px bob and
a +4px hover push), and the heaviest float was being sliced off at
the bottom edge: Starter's CTA rendered cut in half in both themes.
Padding on the parent cannot help, since the clip happens here.
*/}
<div className="relative flex justify-center gap-5 overflow-x-auto px-1 pt-5 pb-10">
{tiers.map((tier, i) => {
const bob = BOB[i % BOB.length]!;
const price = priceOf(tier);
return (
<div
key={tier.name}
className="bf-eq shrink-0"
style={{ "--eq": `${equilibriumPx(tier.weight, billing)}px` } as React.CSSProperties}
>
<div
className="bf-bob"
style={{ "--bob-dur": `${bob.dur}s`, "--bob-delay": `${bob.delay}s` } as React.CSSProperties}
>
<article className="flex w-64 flex-col gap-4 rounded-md border border-border bg-background p-5">
<header>
<div className="flex items-center gap-2">
<h3 className="font-mono text-xs uppercase tracking-widest text-foreground">
{tier.name}
</h3>
{tier.recommended ? (
<span className="rounded-full border border-foreground/30 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-foreground">
Recommended
</span>
) : null}
</div>
<p className="mt-1 text-xs text-ns-muted">{tier.tagline}</p>
</header>
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-semibold tracking-tight tabular-nums text-foreground">
${price}
</span>
<span className="text-sm text-ns-muted">/mo</span>
</div>
<p className="mt-1 h-3.5 font-mono text-[10px] uppercase tracking-widest text-ns-muted">
{annual ? "billed annually" : "billed monthly"}
</p>
</div>
<ul className="flex flex-col gap-1.5">
{tier.features.map((f) => (
<li key={f} className="text-[13px] text-ns-muted">
{f}
</li>
))}
</ul>
<button
type="button"
aria-label={`${tier.cta} — ${tier.name}`}
className={
tier.recommended
? "mt-1 rounded-sm bg-ns-accent px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
: "mt-1 rounded-sm border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors duration-200 hover:border-foreground/25 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
}
>
{tier.cta}
</button>
</article>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
const CSS = `
.bf-eq {
transform: translateY(calc(var(--eq) + var(--bf-hover, 0px)));
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
will-change: transform;
}
.bf-eq:hover,
.bf-eq:focus-within {
--bf-hover: 4px;
}
.bf-bob {
animation: bf-bob-kf var(--bob-dur, 5s) ease-in-out var(--bob-delay, 0s) infinite;
}
@keyframes bf-bob-kf {
0%, 100% { transform: translateY(0px); }
25% { transform: translateY(-3px); }
75% { transform: translateY(3px); }
}
.bf-thumb {
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-reduced-motion: reduce) {
.bf-eq { transition: none; }
.bf-bob { animation: none !important; transform: none !important; }
.bf-thumb { transition: none !important; }
}
`;
Build spec
A three-tier pricing section rendered as one shallow tank: a single SVG water line runs behind the whole card row and each pricing card is a hydrometer float riding in it. GEOMETRY: the water line is one <path>, a two-segment cubic-bezier approximation of a sine wave (up-hump then down-hump across the full width, viewBox 0 0 100 24, preserveAspectRatio=none so it stretches full width), stroke currentColor resolving to --border, vector-effect=non-scaling-stroke for a crisp 1px line at any scale; an SVG <animate> on its `d` morphs it to its vertical mirror and back over a 10s loop (calcMode=spline for an eased, non-linear crest). BUOYANCY: each tier declares a fixed weight class 0..1 (1 = heaviest/sinks lowest, 0 = lightest/rides highest) independent of its literal price — cheaper tiers are conventionally heavier, and the recommended tier is deliberately given the lowest weight of the three so it always rides above the others regardless of price rank. Each card's vertical position is `equilibriumPx = SINK_MIN + weight * density * SINK_SPAN` where density is 1 at monthly billing and 0.55 at annual (annual's cheaper effective price reads as thinner brine — everything gets lighter at once). LAYERING: every card is two nested wrappers around a plain <article> — an outer wrapper carries `transform: translateY(equilibrium + hoverPush)` on a single CSS transition (600ms cubic-bezier(0.34,1.56,0.64,1), a back-out curve giving exactly one visible overshoot) that fires on both a billing-triggered equilibrium change and a hover/focus-within push of +4px (releasing springs it back through the same curve); an inner wrapper runs a separate infinite CSS keyframe bob (±3px) with a distinct duration (4.4/5.6/5.1s) and delay (0/0.9/1.7s) per card so the three phases never lock into sync. The two transforms live on different elements so the CSS animation and the CSS transition never fight over the same `transform` property. LAYOUT: the row wrapper reserves 24px top / 48px bottom padding so the full range of equilibrium + bob + hover motion never clips or reflows anything around it. CONTENT: real <article> cards with an <h3> heading, tagline, price (recomputed from billing state, not a separate write path), a static feature list, and a CTA button — the recommended card's CTA uses --ns-accent, the others are ghost-bordered. BILLING CONTROL: a native role=switch button (not a segmented pair) with aria-checked reflecting annual/monthly and aria-labelledby pointing at the visible Monthly/Annual text pair either side of it — the aria-checked announcement plus the visible price text is the whole story, so no per-frame live region is needed. A11Y: tab order follows document order regardless of float height (bobbing and equilibrium are transform-only, layout position is untouched); the switch is the first interactive control in document order, before any card CTA. REDUCED MOTION: `prefers-reduced-motion: reduce` removes the SVG <animate> entirely (static path), removes the equilibrium transition (billing changes snap instantly, no overshoot) and removes the bob keyframe animation outright — cards still reposition on billing change, just without motion.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tiers? | BrineTier[] | DEFAULT_TIERS | — |
| annualMultiplier? | number | 0.8 | annual billing price multiplier applied to the monthly-equivalent shown, 0.8 = 20% off |
| className? | string | — | — |