InputPassword
A password field built on Input. It brings the four things a password field needs and an app should not have to rebuild: a
visibility toggle that names the next action, the right autocomplete for
the job, a Caps Lock warning, and an optional strength meter.
trailingSlot and a type swap, and adds the parts that were usually skipped: the accessible name of the toggle, the Caps Lock
warning, and a strength announcement that does not fire on every keystroke.Playground
Experiment with different props in real-time.
Basic Usage
One prop. The toggle and the Caps Lock warning are on by default; the meter is not.
<script lang="ts">
import { InputPassword } from 'sv5ui';
let password = $state('');
</script>
<InputPassword bind:value={password} placeholder="Password" />Purpose
purpose sets the autocomplete attribute, which is how a password manager tells a sign-in field from a new-password field.
Get it wrong and the manager either offers the saved password where a new one is wanted, or offers
to generate one on a login form.
purpose
| Value | autocomplete | Use it for |
|---|---|---|
login | current-password | A sign-in form. The default |
signup | new-password | Creating an account, so a manager offers to generate one |
change | new-password | Changing an existing password |
<!-- purpose picks the autocomplete value a password manager reads -->
<InputPassword purpose="login" placeholder="Password" /> <!-- current-password -->
<InputPassword purpose="signup" placeholder="Password" /> <!-- new-password -->
<InputPassword purpose="change" placeholder="Password" /> <!-- new-password -->Visibility Toggle
The button's accessible name is the action it performs next, so a screen reader hears "Show
password" while the value is hidden, and aria-pressed carries the state. It is a real button in the tab order, not an icon with a click handler.
<!-- The toggle is on by default. Its accessible name is the next action, so it
reads "Show password" while the value is hidden and "Hide password" once it
is visible, with aria-pressed following the state. -->
<InputPassword placeholder="Default toggle" />
<!-- No toggle at all -->
<InputPassword toggle={false} placeholder="No toggle" />
<!-- Bindable, so the state can be shared or set from elsewhere -->
<InputPassword bind:visible placeholder="Controlled" />
<Button label={visible ? 'Hide' : 'Show'} onclick={() => (visible = !visible)} />
<!-- Your own icons and names -->
<InputPassword
showIcon="lucide:lock"
hideIcon="lucide:lock-open"
showLabel="Reveal password"
hideLabel="Conceal password"
/>Caps Lock Warning
The warning appears after the first key press while Caps Lock is on. It clears the moment Caps Lock is released, and again when the field loses focus. It reads the modifier state from the key event rather than guessing from the characters typed, so it is right even when the password is all capitals on purpose. Turn Caps Lock on and type in the first field to see it.
<!-- On by default: the warning appears after a key press while Caps Lock is on,
and clears when Caps Lock is released or the field loses focus. It reads the
modifier state from the key event, so it never guesses from the characters
typed. -->
<InputPassword placeholder="Press Caps Lock, then type" />
<InputPassword capsLockMessage="Caps Lock is on, passwords are case sensitive" />
<InputPassword capsLockWarning={false} placeholder="No warning" />Strength Meter
strength is an index into strengthLevels, so 0 is the first level and the meter is hidden while it is null.
The component never scores a password itself: scoring without a dictionary is misleading,
and a rule set that suits one policy penalises another. Count your own rules, or hand the
value to a library such as zxcvbn.
<script lang="ts">
import { InputPassword } from 'sv5ui';
let password = $state('');
// The component never scores a password for you. Scoring without a dictionary
// is misleading, and a rule set that suits one policy penalises another, so
// the number is yours: count rules, or hand the value to zxcvbn.
const rules = [/.{8,}/, /\d/, /[a-z]/, /[A-Z]/];
const met = $derived(rules.filter((rule) => rule.test(password)).length);
// strength is an index into strengthLevels, so 4 rules met maps to the last
// of the four default levels.
const strength = $derived(password ? Math.max(0, met - 1) : null);
</script>
<!-- strength={null} hides the meter entirely, which is the default -->
<InputPassword bind:value={password} {strength} placeholder="Password" />The names are also the steps, so five of them means five segments under the bar. The colour comes from the level's index, and a score past the end is clamped rather than thrown away.
<!-- strengthLevels sets both the wording and the number of steps, and it is the
Progress max, so five names means five segments. strength is clamped into
the range, so a score of 9 lands on the last level rather than throwing. -->
<InputPassword
bind:value={password}
strength={score}
strengthLevels={['Very weak', 'Weak', 'Fair', 'Strong', 'Excellent']}
/>
<!-- Two levels work too -->
<InputPassword bind:value={password} strength={score} strengthLevels={['Rejected', 'Accepted']} />Announcements
The meter brings a visually hidden status region with it. It speaks only when the level changes, and after a 400ms pause, so typing a long password does not read a paragraph of strength updates to a screen reader.
// The meter carries a visually hidden role="status" aria-live="polite" region.
// It says:
'Password strength: Strong'
// Two things it deliberately does not do:
// 1. it does not fire on every keystroke. The announcement is debounced by
// 400ms, so typing a long password does not flood a screen reader.
// 2. it does not repeat. Only a change of level speaks; going from 9 to 10
// characters inside the same level stays silent.
//
// The bar itself is a Progress with aria-label="Password strength", so the level
// is also readable on demand rather than only when it changes.Pasting Is Never Blocked
There is no paste handler in the component, so a password manager fills the field like any other input. Blocking paste pushes people towards passwords short enough to type by hand, which is the opposite of what the block is meant to achieve.
<!-- Nothing here blocks pasting: the component attaches no paste handler, so a
password manager fills the field like any other input. Blocking paste is a
habit that makes people choose weaker passwords they can type by hand, and
it is worth not copying. -->
<InputPassword purpose="login" placeholder="Paste from your password manager" />Footer Snippet
footer renders
under the field, after the meter and the warning, and receives the value and the clamped strength.
A requirement checklist is the usual reason to reach for it.
- At least 8 characters
- At least 1 number
- At least 1 lowercase letter
- At least 1 uppercase letter
<!-- footer renders under the field, after the meter and the caps lock warning.
It receives the current value and the clamped strength. -->
<InputPassword bind:value={password} {strength} footer={hints} />
{#snippet hints({ value, strength }: { value: string; strength: number | null })}
<ul class="space-y-1">
{#each rules as rule (rule.text)}
{@const met = rule.regex.test(value)}
<li class="flex items-center gap-1 {met ? 'text-success' : 'text-on-surface/50'}">
<Icon name={met ? 'lucide:circle-check' : 'lucide:circle-x'} class="size-4 shrink-0" />
<span class="text-xs">{rule.text}</span>
</li>
{/each}
</ul>
{/snippet}Everything Input Takes
Variants, colours, sizes, a leading icon, loading, disabled, highlight and FormField wiring all work, because this is an Input underneath. Slot content is inert while the field is
disabled or loading, so the toggle cannot be pressed in either state.
<!-- Everything Input takes, this takes: variants, colors, sizes, a leading icon,
loading, disabled, highlight, and FormField wiring. -->
<InputPassword variant="soft" color="secondary" size="lg" leadingIcon="lucide:key-round" />
<InputPassword loading placeholder="Checking..." />
<InputPassword disabled value="cannot-edit" />
<FormField label="Password" name="password" hint="Required" error={passwordError}>
<InputPassword purpose="login" />
</FormField>Props it does not accept
Passing one of these is a type error, not a silent override.
| Prop | Why |
|---|---|
type | Follows visible: password or text |
autocomplete | Follows purpose |
trailingSlot | Holds the visibility toggle |
trailingIcon | Same place as the toggle |
icon | Would land trailing when trailing is set |
trailing | Decides which side icon goes to |
// Six Input props are not accepted, because the component owns them:
//
// type driven by visible, password or text
// autocomplete driven by purpose
// trailingSlot holds the visibility toggle
// trailingIcon same place as the toggle
// icon would land trailing when trailing is set
// trailing decides which side icon goes to
//
// Passing one is a type error rather than a silent override. The leading side is
// free: leadingIcon, leadingSlot and avatar all work.A Sign-in Form
The whole point, put together: an email field, a password field that a manager understands, and a submit button.
<script lang="ts">
import { Button, FormField, Input, InputPassword } from 'sv5ui';
let email = $state('');
let password = $state('');
</script>
<form class="space-y-4" onsubmit={(event) => { event.preventDefault(); signIn(email, password); }}>
<FormField label="Email" name="email">
<Input bind:value={email} type="email" autocomplete="email" leadingIcon="lucide:mail" />
</FormField>
<FormField label="Password" name="password">
<InputPassword bind:value={password} purpose="login" leadingIcon="lucide:lock" />
</FormField>
<Button type="submit" label="Sign in" block />
</form>UI Slots
Use the ui prop to override classes. Its own six slots are listed first; anything else goes to Input.
| Slot | Description |
|---|---|
root | The wrapper around the field, the meter, the warning and the footer |
toggle | The visibility button |
meter | The box around the strength Progress |
caps | The Caps Lock warning line |
capsIcon | The icon inside that line |
footer | The box around your footer snippet |
Input slots | root, base, leading, leadingIcon, trailing and the rest pass through to Input |
<!-- Six slots of its own, plus every Input slot, which pass straight through -->
<InputPassword
strength={2}
ui={{
toggle: 'text-primary hover:text-primary',
meter: 'mt-3',
caps: 'text-error',
base: 'font-mono'
}}
/>Snippets
| Snippet | Description |
|---|---|
footer | Rendered under the field. Receives { value, strength }, where strength is already clamped |
leadingSlot | Input's leading snippet, free to use. The trailing side belongs to the toggle |
Props
| Prop | Type | Default |
|---|---|---|
value | string | '' |
purpose | 'login' | 'signup' | 'change' | 'login' |
visible | boolean | false |
toggle | boolean | true |
showIcon | string | 'lucide:eye' |
hideIcon | string | 'lucide:eye-off' |
showLabel | string | 'Show password' |
hideLabel | string | 'Hide password' |
capsLockWarning | boolean | true |
capsLockMessage | string | 'Caps Lock is on' |
strength | number | null | null |
strengthLevels | string[] | ['Weak','Fair','Good','Strong'] |
footer | Snippet<[{ value, strength }]> | - |
ref | HTMLInputElement | null | null |
...InputProps | - | - |
class | string | - |
ui | Record<Slot, Class> | - |