Balance Scale Compare
A balance-scale comparison layout — check feature chips onto either side and the two columns physically sink and rise with their accumulated weight, while the beam on its fulcrum tilts to an angle that reflects the real gap.
npx shadcn@latest add https://webberui.com/r/balance-scale-compare.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<BalanceScaleCompare />
Installation
npx shadcn@latest add https://webberui.com/r/balance-scale-compare.jsonOr, once registries are configured in components.json, install it as @webberui/balance-scale-compare.
Usage
import {
BalanceScaleCompare,
type ScaleFeature,
} from "@/components/ui/balance-scale-compare";
const features: ScaleFeature[] = [
{ id: "price", label: "Price", weight: 3, icon: "💰" },
{ id: "speed", label: "Performance", weight: 2, icon: "⚡" },
{ id: "support", label: "Support", weight: 2, icon: "🎧" },
];
<BalanceScaleCompare
features={features}
leftLabel="Build it yourself"
rightLabel="WebberUI"
defaultAssignments={{ price: "left", speed: "right" }}
/>;In controlled mode, pass assignments and keep the state in sync with onAssignmentsChange:
const [assignments, setAssignments] = React.useState<
Record<string, "left" | "right">
>({});
<BalanceScaleCompare
features={features}
assignments={assignments}
onAssignmentsChange={setAssignments}
/>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
features | ScaleFeature[] | — (required) | List of feature chips that can be assigned to either side |
leftLabel | string | "Plan A" | Heading of the left column |
rightLabel | string | "Plan B" | Heading of the right column |
assignments | Record<string, "left" | "right"> | — | Controlled assignment map; chips not in the map are unassigned |
defaultAssignments | Record<string, "left" | "right"> | {} | Uncontrolled initial assignment map |
onAssignmentsChange | (next: Record<string, "left" | "right">) => void | — | Fires when an assignment changes, carrying the complete new assignment map |
maxTilt | number | 14 | Maximum tilt angle (degrees), the limit reached when all the weight is on one side |
unit | string | "pts" | Unit label for the weights |
className | string | — | Class appended to the outermost container |
ScaleFeature
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — (required) | Unique identifier key |
label | string | — (required) | Display name |
weight | number | 1 | Weight (a positive number); the totals determine how the scale tilts |
icon | React.ReactNode | — | Optional icon (emoji or node), shown before the chip |
How it works
- Physical tilt: the weight difference between the two sides ÷ the total weight is normalized to
-1..1and multiplied bymaxTiltto get the angle, so the scale settles at an angle that reflects the real gap; it reaches the limit when everything is on one side, and returns level when the two sides are equal - Pan sinking: when the beam rotates θ degrees, the vertical offset of each end is
sin(θ) × half the beam length, and each pan sinks or rises along with its end — the heavier side goes down, the lighter side comes up - Layer separation: the beam and the fulcrum pointer belong to the same rotation group that rotates around the fulcrum; the horizontal placement of the pans is handled by an outer element while the vertical sinking is handed to an inner
motionelement, so the two transforms do not interfere with each other - Swing to rest: the beam and the pans share a spring with a slight overshoot, so after an assignment changes it wobbles a few times before settling, just like a real balance scale
- Chips in and out: a chip checked onto a side springs into that pan,
AnimatePresenceshrinks and fades out chips that leave, and the remaining chips fill the gap with a layout animation
Accessibility
- Each side button is a native
buttonwith anaria-label(containing the chip name and the target side's heading) andaria-pressedmarking the current selection, and it can be operated with the keyboard - The scale graphic is hidden from assistive technology with
aria-hidden; a separaterole="status"+aria-live="polite"text region announces which side is currently heavier and by how much - When the user has "reduce motion" enabled at the system level, the beam and pans switch instantly and chips do not scale as they come and go; functionality is entirely unaffected
Milestone Unlock Meter
A full-bleed progress meter — the liquid level rises to fill the whole section from data or scroll, and every time it crosses a threshold the matching milestone card pops open, unlocked and lit.
Changelog Timeline
A release changelog timeline section — version badges, type tags, and a staggered reveal.