# Password requirements

> A checklist under a new-password field that marks each of your declared rules as the password meets it, with an optional meter that counts rules met (never an entropy guess) and a debounced spoken summary.

- ID: `cmp_password_requirements_01`
- Slug: `password-requirements-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T03:59:52Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/password-requirements-01?variant=neutral
- Preview: https://pagesugar.com/preview/password-requirements-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42` |
| `green` | Green success | no | `sha256-f4f2b879d2f8c92a9b7e17b4d8e63ef5d41db6e7602304959eaadf73ea249818` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/password-requirements-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Bind your password input's value to password, pass the same rules your server enforces, and point the input's aria-describedby at id. Each rule is ticked as the password meets it and the meter counts the rules met. It does not enforce rules, block submission, estimate entropy, check breach lists or store the password: your server does the checking.

Required props: `password`, `rules`

```svelte
<script lang="ts">
	import PasswordRequirements, {
		type PasswordRule
	} from '$lib/components/password-requirements-01/PasswordRequirements.svelte';

	const email = 'mira@northwind.example';
	const rules: PasswordRule[] = [
		{ id: 'length', label: 'At least 12 characters', test: (p) => [...p].length >= 12 },
		{ id: 'number', label: 'A number', test: (p) => /\d/.test(p) },
		{
			id: 'email',
			label: 'Something other than your email address',
			test: (p) => !p.toLowerCase().includes(email)
		}
	];

	let password = $state('');
</script>

<label for="new-password">New password</label>
<input
	id="new-password"
	name="new-password"
	type="password"
	autocomplete="new-password"
	aria-describedby="password-rules"
	bind:value={password}
/>
<PasswordRequirements
	id="password-rules"
	{password}
	{rules}
	showMeter
	note="We also check it against known breached passwords when you save."
/>
```

Limitations:

- Nothing here is enforcement. A person can submit a password that fails every rule unless your form or server stops it, and your server must apply the same rules.
- The meter counts rules met. It says nothing about how guessable the password is; for that, run an estimator such as zxcvbn yourself and pass its verdict as a rule or a note.
- Breach checks belong on your server. Show the result through note with noteTone 'error'.
- The server renders the rules already ticked for the password it is given; ticking them as the person types, and the spoken summary, need client JavaScript.
- Rule ids must be unique within rules; they key the list.
- The default summary, meter count and met / not-yet-met labels are English. Pass summary, meterCount, metLabel, unmetLabel and errorPrefix in the page's language.

## Usage guide

### Password requirements

Feedback under a new-password field: your rules, listed up front and ticked as the password meets
them. It pairs with any password input; the field itself is a separate component.

```svelte
<PasswordRequirements id="password-rules" {password} {rules} showMeter />
```

#### The rules are yours, and so is enforcement

Each rule is `{ id, label, test }`. The component calls `test(password)` on every change and
nothing else: no network, no stored value. Write the same checks on your server, because this
block blocks nothing. Lead with length; NIST SP 800-63B advises against composition rules, and a
long passphrase beats a short password with a symbol in it.

```ts
const rules: PasswordRule[] = [
	{ id: 'length', label: 'At least 12 characters', test: (p) => [...p].length >= 12 },
	{ id: 'max', label: 'No more than 64 characters', test: (p) => [...p].length <= 64 },
	{
		id: 'email',
		label: 'Something other than your email address',
		test: (p) => !p.toLowerCase().includes(email)
	}
];
```

Count characters with `[...p].length`, so an emoji counts as one, as most servers count it.

#### The meter counts rules

`showMeter` adds one segment per rule, lit as rules are met, and a label beside the heading:
a short count, `3 of 4` (`meterCount`), or one of your `meterLabels`. The last label appears only
when every rule is met, so the meter can never call a password ready that a rule would reject.
It is not a strength estimate.

To add zxcvbn or another estimator, load it in your page and pass its verdict as one more rule,
for example `{ id: 'guess', label: 'Hard to guess', test: () => score >= 3 }`. The bundle stays
yours to choose.

#### Server results

A breach check happens on your server. Say so up front with `note`, and when the server rejects
the password, pass its message with `noteTone="error"`:

```svelte
<PasswordRequirements
	{password}
	{rules}
	note="This password has appeared in a known data breach. Choose a different one."
	noteTone="error"
/>
```

The note is not announced on its own. After a failed submit, move focus to the field or to an
error summary, as the rest of your form does.

#### Screen readers

The input reads the heading and every rule when it takes focus, through `aria-describedby`.
While typing, nothing is announced per keystroke. One polite summary is spoken after
`announceDelay` (1000 ms) of quiet. Translate it with `summary`, and `metLabel`, `unmetLabel`
and `errorPrefix` with it.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `password` | `string` | yes |  | The current password. Every rule is tested against it on each change. Empty, no rule counts as met. |
| `rules` | `PasswordRule[]` | yes |  | The declared rules, { id, label, test }, in the order to list them, with unique ids. test(password) returns true when the rule is met. Enforce the same rules on the server. |
| `id` | `string` | no |  | Id of the rules block, for the password input's aria-describedby. Defaults to one generated per instance. |
| `heading` | `string` | no | `'Your password needs'` | The line above the rules. It also names the meter. |
| `showMeter` | `boolean` | no | `false` | Show the meter: one segment per rule, lit as rules are met, with its label beside the heading. Nothing shows while rules is empty. |
| `meterLabels` | `string[]` | no |  | Two or more words for the meter, lowest first, in place of the count. The last shows only when every rule is met; a single label is ignored. |
| `note` | `string` | no |  | A line under the rules, such as 'We also check it against known breached passwords when you submit', or the server's rejection. |
| `noteTone` | 'info' \| 'error' | no | `'info'` | 'error' sets the note in the danger colour with an alert mark and a hidden errorPrefix, for a rejection from your server. |
| `layout` | 'list' \| 'compact' | no | `'list'` | 'compact' sets short rules in two columns once the block is 20rem wide. |
| `metLabel` | `string` | no | `'Met:'` | Visually hidden text read before a met rule. |
| `unmetLabel` | `string` | no | `'Not yet met:'` | Visually hidden text read before a rule not yet met. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before an error note. |
| `summary` | `(met: number, total: number) => string` | no | `'3 of 4 requirements met', or 'All requirements met'` | The sentence announced once typing pauses. Translate it with the page. |
| `meterCount` | `(met: number, total: number) => string` | no | `'3 of 4'` | The meter's short count, shown beside the heading and used as its aria-valuetext when there are no meterLabels. |
| `reserveNote` | `number` | no | `0` | Lines (20 px each, plus the 12 px gap) to hold under the rules for a note you set later, such as the server's rejection, so it moves nothing below. Set it to the longest message's line count at your narrowest width; a longer note still grows the block. |
| `announceDelay` | `number` | no | `1000` | Milliseconds of quiet after the last change before the summary is announced. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change the rules and copy through props and retone the block through six --password-requirements-\* CSS variables. The unmet ring is mixed from muted, so it follows any retone.

- Rules: write each label as a target ('At least 12 characters', 'A number'), keep the test the same as your server's check, and lead with length. Long passphrases beat symbol rules.
- Accent: --password-requirements-accent fills met marks and lit meter segments; --password-requirements-on-accent is the check drawn on it. The default is near-black. The Green success palette sets #15803d.
- Neutrals: --password-requirements-ink is the heading, every rule before typing and the rules still to meet; --password-requirements-muted the rules already met, the meter count and the note; --password-requirements-hairline the unlit segments. The unmet ring is muted at 70%, so keep muted dark enough for that ring to clear 3:1.
- Danger: --password-requirements-danger colours an error note. The default is red-700 (6.5:1 on white).
- Worked retone for a #09090b band: --password-requirements-accent: #fafafa; --password-requirements-on-accent: #09090b; --password-requirements-ink: #fafafa; --password-requirements-muted: #a1a1aa; --password-requirements-hairline: rgb(255 255 255 / 0.14); --password-requirements-danger: #f87171.
- Meter: leave it off for two or three rules, where the list says enough. With meterLabels, make the last one mean 'every rule met' and nothing stronger.
- Placement: put it directly under the password field, inside the same field group, and before the submit button. Point the input's aria-describedby at id.
- Strength estimation: to add zxcvbn or similar, run it in your page and pass its result as a rule ('Hard to guess') so the meter still counts only what you check.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--password-requirements-accent` |
| `onAccent` | `--password-requirements-on-accent` |
| `ink` | `--password-requirements-ink` |
| `muted` | `--password-requirements-muted` |
| `hairline` | `--password-requirements-hairline` |
| `danger` | `--password-requirements-danger` |

## Accessibility

- Point your password input's aria-describedby at id, so the heading and every rule are read when the field takes focus.
- The rules are a list, with role="list" so Safari keeps the semantics without markers. Each item starts with visually hidden 'Met:' or 'Not yet met:', so state is never colour or shape alone; translate both with the page.
- A separate polite, atomic live region outside the described block announces one summary ('3 of 4 requirements met') a second after typing pauses, emptied at the start of each burst so the same summary is spoken again. Page load announces nothing.
- The meter is role=meter, named by the heading, with aria-valuenow counting met rules and aria-valuetext equal to its visible label.
- An error note carries an alert mark and a hidden errorPrefix. It is not a live region: when the server rejects a password, move focus or announce the error as your form decides.
- Forced-colours mode draws met marks and lit segments in Highlight. Motion is a short fade and stops under reduced motion.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `PasswordRequirements.svelte`
- Suggested directory: `src/lib/components/password-requirements-01`
- Files: 1
- Artifact digest: `sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `PasswordRequirements.svelte`

Role: entry · 10904 bytes · SHA-256 `27436a0d04f9e1a565cb051fae66e988f6d537e408ed6f756cbad363ea6e0457`

```svelte
<!--
	Password requirements: the rules a new password has to meet, listed up front under the field
	and marked as the password meets them. The optional meter is the checklist counted, one
	segment per rule, so it never claims more than the rules do. Everything is evaluated locally
	from the rules you pass; nothing is sent anywhere, and nothing here enforces a rule. Your
	server must check the same rules.
-->
<script lang="ts" module>
	export interface PasswordRule {
		/** Stable key for the rule. */
		id: string;
		/** What the password needs, worded as a target: 'At least 12 characters'. */
		label: string;
		/** True when the password meets the rule. Runs on every change, so keep it cheap. */
		test: (password: string) => boolean;
	}
	export type PasswordRequirementsLayout = 'list' | 'compact';
	export type PasswordRequirementsNoteTone = 'info' | 'error';
</script>

<script lang="ts">
	interface Props {
		/** The current password. Rules are checked against it on every change. */
		password: string;
		/** The declared rules, in the order to list them. Enforce the same rules on the server. */
		rules: PasswordRule[];
		/** Id of the described block, for the password input's aria-describedby. */
		id?: string;
		/** Line above the rules. */
		heading?: string;
		/** Show the rule-count meter beside the heading. */
		showMeter?: boolean;
		/** Text for each meter level, lowest first. The last is used only when every rule is met. */
		meterLabels?: string[];
		/** Extra information under the rules, such as a check that happens on submit. */
		note?: string;
		/** 'error' sets the note in the danger colour with an alert mark, for a server rejection. */
		noteTone?: PasswordRequirementsNoteTone;
		/** 'compact' sets short rules in two columns once there is room. */
		layout?: PasswordRequirementsLayout;
		/** Read before a met rule by screen readers. */
		metLabel?: string;
		/** Read before a rule not yet met by screen readers. */
		unmetLabel?: string;
		/** Read before an error note by screen readers. */
		errorPrefix?: string;
		/** The sentence announced after typing pauses. */
		summary?: (met: number, total: number) => string;
		/** The meter's short count, shown and used as its aria-valuetext when there are no meterLabels. */
		meterCount?: (met: number, total: number) => string;
		/** Lines to hold under the rules for a note set later, so a server reply moves nothing below. */
		reserveNote?: number;
		/** Milliseconds of quiet before the summary is announced. */
		announceDelay?: number;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		password,
		rules,
		id,
		heading = 'Your password needs',
		showMeter = false,
		meterLabels,
		note,
		noteTone = 'info',
		layout = 'list',
		metLabel = 'Met:',
		unmetLabel = 'Not yet met:',
		errorPrefix = 'Error:',
		summary = (met: number, total: number) =>
			met === total ? 'All requirements met' : `${met} of ${total} requirements met`,
		meterCount = (met: number, total: number) => `${met} of ${total}`,
		reserveNote = 0,
		announceDelay = 1000,
		class: className
	}: Props = $props();

	const uid = $props.id();
	const blockId = $derived(id ?? `${uid}-requirements`);
	const headingId = `${uid}-heading`;

	/* Before the first character nothing is met, so a rule like 'not your email' starts neutral. */
	const started = $derived(password.length > 0);
	const results = $derived(rules.map((rule) => started && rule.test(password)));
	const total = $derived(rules.length);
	const met = $derived(results.filter(Boolean).length);

	/* The last label means every rule is met; the rest share the partial range. */
	function levelLabel(labels: string[], count: number, of: number) {
		if (count === of) return labels[labels.length - 1];
		return labels[Math.min(labels.length - 2, Math.floor((count / of) * (labels.length - 1)))];
	}

	/* A single label could only mean 'all met', so it needs a partner to replace the count. */
	const labels = $derived(meterLabels && meterLabels.length >= 2 ? meterLabels : null);
	/* A meter needs a range; with no rules there is nothing to count. */
	const meterShown = $derived(showMeter && total > 0);
	const meterText = $derived(labels ? levelLabel(labels, met, total) : meterCount(met, total));
	/* Every text the meter can show, stacked in one cell so the widest holds the width. */
	const meterTexts = $derived([
		...new Set(labels ?? [meterCount(total, total), meterCount(0, total)])
	]);

	/*
	 * The summary is announced once typing pauses, never per keystroke. Each burst of changes
	 * empties the region first, so the same summary is spoken again after the next pause. The
	 * first run only records the starting state, so a page load announces nothing.
	 */
	let announcement = $state('');
	let primed = false;
	$effect(() => {
		void password;
		const text = summary(met, total);
		if (!primed) {
			primed = true;
			return;
		}
		announcement = '';
		const timer = setTimeout(() => (announcement = text), announceDelay);
		return () => clearTimeout(timer);
	});
</script>

<div class={['password-requirements @container min-w-0', className]} data-layout={layout}>
	<div id={blockId}>
		<div class="flex items-baseline gap-4">
			<p id={headingId} class="min-w-0 flex-1 text-[13px] leading-5 font-medium text-(--_ink)">
				{heading}
			</p>
			{#if meterShown}
				<span class="grid shrink-0 text-end text-[13px] leading-5 text-(--_muted) tabular-nums">
					{#each meterTexts as text, i (i)}
						<span class={['col-start-1 row-start-1', text !== meterText && 'invisible']}
							>{text}</span
						>
					{/each}
					{#if !meterTexts.includes(meterText)}
						<span class="col-start-1 row-start-1">{meterText}</span>
					{/if}
				</span>
			{/if}
		</div>

		{#if meterShown}
			<div
				class="mt-2 flex gap-1"
				role="meter"
				aria-labelledby={headingId}
				aria-valuemin={0}
				aria-valuemax={total}
				aria-valuenow={met}
				aria-valuetext={meterText}
			>
				{#each { length: total }, i}
					<span
						class={[
							'h-1 flex-1 rounded-full transition-[background-color] duration-150 ease-(--_ease) motion-reduce:transition-none',
							'forced-colors:forced-color-adjust-none',
							i < met
								? 'bg-(--_accent) forced-colors:bg-[Highlight]'
								: 'bg-(--_track) forced-colors:bg-[GrayText]'
						]}
					></span>
				{/each}
			</div>
		{/if}

		<!-- role="list" keeps list semantics in Safari, which drops them once markers are removed. -->
		<ul
			role="list"
			class={[
				'mt-3 grid gap-y-2',
				layout === 'compact' && '@xs:grid-cols-2 @xs:gap-x-4 @xs:gap-y-1'
			]}
		>
			{#each rules as rule, i (rule.id)}
				{@const ok = results[i]}
				<li
					class={[
						'group flex min-w-0 items-start gap-2 text-[13px] leading-5 transition-[color] duration-150 ease-(--_ease) motion-reduce:transition-none',
						/* Before typing every rule reads as an instruction; after, what is left stays in ink and
						   what is done steps back, so the next thing to do is the darkest line. */
						ok ? 'text-(--_muted)' : 'text-(--_ink)'
					]}
					data-met={ok ? '' : undefined}
				>
					<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
						<svg class="size-4" viewBox="0 0 16 16" fill="none">
							<!-- At rest a quiet ring; once met, a filled dot with a check. -->
							<circle
								class="stroke-(--_ring) transition-opacity duration-150 ease-(--_ease) group-data-met:opacity-0 motion-reduce:transition-none forced-colors:stroke-[CanvasText]"
								cx="8"
								cy="8"
								r="6.25"
								stroke-width="1.5"
							/>
							<circle
								class="fill-(--_accent) opacity-0 transition-opacity duration-150 ease-(--_ease) group-data-met:opacity-100 motion-reduce:transition-none forced-colors:fill-[Highlight]"
								cx="8"
								cy="8"
								r="7"
							/>
							<path
								class="stroke-(--_on-accent) opacity-0 transition-opacity duration-150 ease-(--_ease) group-data-met:opacity-100 motion-reduce:transition-none forced-colors:stroke-[HighlightText]"
								d="M5.25 8.25 7.1 10l3.65-4"
								stroke-width="1.75"
								stroke-linecap="round"
								stroke-linejoin="round"
							/>
						</svg>
					</span>
					<span class="min-w-0 text-pretty break-words"
						><span class="sr-only">{`${ok ? metLabel : unmetLabel} `}</span>{rule.label}</span
					>
				</li>
			{/each}
		</ul>

		{#if note}
			<p
				style:--_note-lines={reserveNote || undefined}
				class={[
					'mt-3 flex min-h-[calc(var(--_note-lines,0)*1.25rem)] items-start gap-2 text-[13px] leading-5',
					noteTone === 'error' ? 'text-(--_danger)' : 'text-(--_muted)'
				]}
			>
				<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
					{#if noteTone === 'error'}
						<svg class="size-4" viewBox="0 0 16 16" fill="none">
							<path
								d="M8 1.75 14.75 13.5H1.25L8 1.75Z"
								stroke="currentColor"
								stroke-width="1.5"
								stroke-linejoin="round"
							/>
							<path d="M8 6.25v3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
							<circle cx="8" cy="11.25" r="0.875" fill="currentColor" />
						</svg>
					{:else}
						<svg class="size-4" viewBox="0 0 16 16" fill="none">
							<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
							<path
								d="M8 7.25v3.5"
								stroke="currentColor"
								stroke-width="1.5"
								stroke-linecap="round"
							/>
							<circle cx="8" cy="5" r="0.875" fill="currentColor" />
						</svg>
					{/if}
				</span>
				<span class="min-w-0 text-pretty break-words"
					>{#if noteTone === 'error'}<span class="sr-only">{`${errorPrefix} `}</span
						>{/if}{note}</span
				>
			</p>
		{:else if reserveNote > 0}
			<div
				style:--_note-lines={reserveNote}
				class="mt-3 min-h-[calc(var(--_note-lines,0)*1.25rem)]"
				aria-hidden="true"
			></div>
		{/if}
	</div>

	<!-- Outside the described block, so the input's description never repeats the summary. -->
	<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{announcement}</div>
</div>

<style>
	/* Public tokens: set --password-requirements-* on the block or any ancestor to retone it. */
	.password-requirements {
		--_accent: var(--password-requirements-accent, #18181b);
		--_on-accent: var(--password-requirements-on-accent, #ffffff);
		--_ink: var(--password-requirements-ink, #18181b);
		--_muted: var(--password-requirements-muted, #52525b);
		--_hairline: var(--password-requirements-hairline, rgb(0 0 0 / 0.12));
		--_danger: var(--password-requirements-danger, #b91c1c);

		--_ease: cubic-bezier(0.2, 0, 0, 1);
		/* Formulas: an unlit meter segment and an unmet ring, both read from the tokens. */
		--_track: var(--_hairline);
		/* 70% of muted keeps the unmet ring above 3:1 on white, since it carries state. */
		--_ring: color-mix(in oklab, var(--_muted) 70%, transparent);
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42`
- Entry: `PasswordRequirements.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_password_requirements_01/1.0.0/neutral/sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_password_requirements_01/1.0.0/neutral/sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42/bundle.zip (7624 bytes, sha256 `7b113f54966bd682f6651ceb05f70468300fe45ac9107895158c0fc99e023d54`)

Files:

- `PasswordRequirements.svelte` (entry, 10904 bytes): https://pagesugar.com/artifacts/cmp_password_requirements_01/1.0.0/neutral/sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42/source/PasswordRequirements.svelte
