All patterns

Password Strength Bar

A segmented meter fills bar by bar and shifts hue as a password improves.

feedbackminimalcalmautomatic · finite · starter · ~0.7s
Variant

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.

225 lines · react + motion only
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.

Sign-up password fieldChange password screenPassphrase quality hintAccount security settings

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Settings
    General
    Notifications
    Members
    Billing
    SettingsNew
    Desktop notificationsAlert on mention and reply
    Weekly digestEvery Monday at 09:00
    SoundsPlay a tone for new messages
    Follow repliesTrack threads you post in
    Settings

    Password generator shows a strength scale that recolors as the recipe changes.

Related patterns