Skip to content
Download ZIP

Neutral palette · 7.4 KB ZIP File receipt View as Markdown View code

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.
cmp_password_requirements_01 · version 1.0.0 · Neutral palette
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
PasswordRequirements.svelte Svelte · 10.6 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/password-requirements-01
Required props
passwordrules

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.

Example

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."
/>

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.

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.

TypeScript
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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
passwordstringYesNoneThe current password. Every rule is tested against it on each change. Empty, no rule counts as met.
rulesPasswordRule[]YesNoneThe 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.
idstringNoNoneId of the rules block, for the password input's aria-describedby. Defaults to one generated per instance.
headingstringNo'Your password needs'The line above the rules. It also names the meter.
showMeterbooleanNofalseShow the meter: one segment per rule, lit as rules are met, with its label beside the heading. Nothing shows while rules is empty.
meterLabelsstring[]NoNoneTwo 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.
notestringNoNoneA 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.
metLabelstringNo'Met:'Visually hidden text read before a met rule.
unmetLabelstringNo'Not yet met:'Visually hidden text read before a rule not yet met.
errorPrefixstringNo'Error:'Visually hidden text read before an error note.
summary(met: number, total: number) => stringNo'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) => stringNo'3 of 4'The meter's short count, shown beside the heading and used as its aria-valuetext when there are no meterLabels.
reserveNotenumberNo0Lines (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.
announceDelaynumberNo1000Milliseconds of quiet after the last change before the summary is announced.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--password-requirements-accentaccent
--password-requirements-on-accentonAccent
--password-requirements-inkink
--password-requirements-mutedmuted
--password-requirements-hairlinehairline
--password-requirements-dangerdanger

Accessibility#

On this page
  • 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.

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.