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.
cmp_password_requirements_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_password_requirements_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-c640eb7de4c344e7d412df9959877e18915127a155255d27f77a0762611d9c42
<!--
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>
Usage#
On this pageBind 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.
- Suggested location
src/lib/components/password-requirements-01
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,unmetLabelanderrorPrefixin the page's language.
Example
<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."
/>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.
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.
<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.
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":
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
password | string | Yes | None | The current password. Every rule is tested against it on each change. Empty, no rule counts as met. |
rules | PasswordRule[] | Yes | None | 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 | None | 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 | None | 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 | None | 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 | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange 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-accentfills met marks and lit meter segments;--password-requirements-on-accentis the check drawn on it. The default is near-black. The Green success palette sets#15803d. - Neutrals:
--password-requirements-inkis the heading, every rule before typing and the rules still to meet;--password-requirements-mutedthe rules already met, the meter count and the note;--password-requirements-hairlinethe unlit segments. The unmet ring is muted at 70%, so keep muted dark enough for that ring to clear 3:1. - Danger:
--password-requirements-dangercolours an error note. The default isred-700(6.5:1 on white). - Worked retone for a
#09090bband:--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-describedbyat 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.
Public CSS variables
| Variable | Token |
|---|---|
--password-requirements-accent | accent |
--password-requirements-on-accent | onAccent |
--password-requirements-ink | ink |
--password-requirements-muted | muted |
--password-requirements-hairline | hairline |
--password-requirements-danger | danger |
Accessibility#
On this page- Point your password input's
aria-describedbyat 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, witharia-valuenowcounting met rules andaria-valuetextequal 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.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.