Password Strength Bar
A segmented meter fills bar by bar and shifts hue as a password improves.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Password Strength Bar
*
* A segmented meter whose bars fill left to right and shift hue as a
* password gets stronger, with the tier word and the hint crossfading
* underneath.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The track is mixed from the inherited text color, so the meter reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `strength`, `label`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PasswordStrengthBarProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Tier 0–4: 0 empty, 4 strongest. Drive it from your own scorer. */
strength?: number;
/** Row label on the left of the tier word. */
label?: string;
/** Replaces the built-in guidance line under the meter. */
hint?: string;
/** Bar count. The tier scale always spans them. */
segments?: number;
};
type VariantConfig = {
/** Gap between each bar starting to fill. */
stagger: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** How long the hue takes to travel to the new tier color. */
hueSeconds: number;
};
// Damping ratios (damping / 2√stiffness) stay at or above 0.8: a meter
// that overshoots its own value is telling the reader something untrue
// for a few frames. Variants differ in pace and cascade, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// No overshoot, near-simultaneous bars. For dense sign-up forms.
subtle: {
stagger: 0.03,
spring: { type: "spring", stiffness: 480, damping: 44 },
hueSeconds: 0.24,
},
// A readable left-to-right cascade. The all-purpose setting.
default: {
stagger: 0.05,
spring: { type: "spring", stiffness: 420, damping: 40 },
hueSeconds: 0.3,
},
// Longer cascade and a single soft settle for a hero account screen.
playful: {
stagger: 0.07,
spring: { type: "spring", stiffness: 360, damping: 32 },
hueSeconds: 0.34,
},
};
/** Semantic colors stay literal — the tier hue is the message. */
const TIERS = [
{ word: "Too short", color: "#E0564D", hint: "Use at least 8 characters." },
{ word: "Weak", color: "#E0564D", hint: "Add a number or a symbol." },
{ word: "Fair", color: "#E09B3D", hint: "Mix in upper and lower case." },
{ word: "Good", color: "#7FB03F", hint: "Add one more word to finish." },
{ word: "Strong", color: "#2FA36B", hint: "Long enough to be hard to guess." },
];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function PasswordStrengthBar({
variant = "default",
strength = 3,
label = "Password strength",
hint,
segments = 4,
}: PasswordStrengthBarProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const level = Math.min(TIERS.length - 1, Math.max(0, Math.round(strength)));
const tier = TIERS[level];
// Tier 0 fills nothing, so its bars borrow tier 1's hue rather than
// animating toward a color no bar will ever show.
const fillColor = TIERS[Math.max(1, level)].color;
const filledCount = Math.round((level / (TIERS.length - 1)) * segments);
return (
<div style={{ width: 268, display: "grid", gap: 8 }}>
<div
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={TIERS.length - 1}
aria-valuenow={level}
aria-valuetext={tier.word}
style={{ display: "flex", gap: 6 }}
>
{Array.from({ length: segments }, (_, index) => {
const filled = index < filledCount;
return (
<div
key={index}
style={{
flex: 1,
height: 5,
borderRadius: 999,
background: tone(14),
overflow: "hidden",
}}
>
{/* scaleX, not width: a transform costs nothing per frame
where width would relayout the whole row. */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: filled ? 1 : 0, backgroundColor: fillColor }}
transition={
reduceMotion
? {
// Movement dropped, information kept: the bar is
// simply there at its value, and only the hue eases.
scaleX: { duration: 0 },
backgroundColor: { duration: 0.15, ease: "easeOut" },
}
: {
scaleX: {
...cfg.spring,
delay: filled ? index * cfg.stagger : 0,
},
backgroundColor: {
duration: cfg.hueSeconds,
ease: "easeOut",
},
}
}
style={{
height: "100%",
borderRadius: 999,
transformOrigin: "left center",
background: fillColor,
}}
/>
</div>
);
})}
</div>
<div
style={{
position: "relative",
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{label}</span>
{/* Both words are laid out in the same place and crossfaded, so
the row never reflows as the tier changes — and the type size
is constant, because text must not scale. */}
<span
style={{
position: "relative",
minWidth: 68,
height: 16,
flexShrink: 0,
}}
>
{TIERS.map((entry, index) => (
<motion.span
key={entry.word}
aria-hidden={index !== level}
initial={false}
// Tier 0 has nothing to celebrate, so its word sits muted
// rather than colored.
animate={{
opacity: index !== level ? 0 : index === 0 ? 0.55 : 1,
}}
transition={{ duration: reduceMotion ? 0.12 : 0.22, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
textAlign: "right",
fontSize: 12,
fontWeight: 600,
lineHeight: "16px",
color: index === 0 ? "inherit" : entry.color,
pointerEvents: "none",
}}
>
{entry.word}
</motion.span>
))}
</span>
</div>
<div style={{ position: "relative", height: 15 }}>
{TIERS.map((entry, index) => (
<motion.span
key={entry.word}
aria-hidden={index !== level}
initial={false}
animate={{ opacity: index === level ? 0.5 : 0 }}
transition={{ duration: reduceMotion ? 0.12 : 0.22, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 11.5,
lineHeight: "15px",
pointerEvents: "none",
}}
>
{index === level && hint ? hint : entry.hint}
</motion.span>
))}
</div>
</div>
);
}About this pattern
Live feedback while someone chooses a password. Each bar scales in from its left edge in a quick left-to-right cascade, and the whole row travels through a hue scale — red, amber, olive, green — as the tier changes. The tier word and the guidance line sit in fixed slots and crossfade in place, so the type size never changes and the form never reflows under the cursor. Judgement matters more than drama here: the meter is telling the reader something factual, so it settles on its value without overshooting past it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Settings
Password generator shows a strength scale that recolors as the recipe changes.
Related patterns
- Offline Banner DropA connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.
- Session Expiry CountdownA session-timeout dialog counts down on a draining ring with a clear stay-signed-in action.
- Validation Summary ListA rejected submit answers with the whole list of what stopped it, each entry arriving a beat apart.