In React it is a component you pass props to. A type="password" field gets its reveal toggle for free, because a password the visitor cannot read back is the most common reason a sign-in fails on the first try. Everything else - the generator, the strength meter, the breach check - is one prop, and off until you ask for it.
Customize
Nothing here is on until you ask for it, so this panel starts where the component does. Type into it, generate one, reveal it - the preview is the real field and the code below is generated from the same values.
Usage
Installation
Adds the package and prints the import to use. Append --copy to write the source into your project instead.
enigma add inputInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react.
This is exactly what enigma add input --copy writes into your project - read from the
installed package at build time, so it cannot drift from what you actually get.
"use client";
import { useState } from "react";
import { Input, type BreachChecker, type BreachState } from "@enigmax/primitives/react";
/**
* A registration password field, styled with Tailwind. Yours to edit.
*
* The primitive renders the structure and publishes its state on `data-*`; every colour
* below is this file's. The meter's five bars read the score from the ROOT, which is why
* they colour themselves through a group variant rather than five separate components.
*/
interface PasswordFieldProps {
value: string;
onChange: (value: string) => void;
/** What the visitor has already typed elsewhere, so the meter can spot it in there. */
userInputs?: string[];
/**
* Check the password against a breach corpus. `enigma add password-breach` gives you
* `checkPasswordBreach` from @enigmax/utils, which asks Have I Been Pwned without
* sending the password anywhere. Leave it out and the field simply does not check.
*/
breach?: BreachChecker;
/** Your own message, from your own validation. */
error?: string;
}
const SEGMENT = [
"h-1 rounded-full bg-neutral-800 transition-colors",
"group-data-[score=0]/field:data-[filled]:bg-red-600",
"group-data-[score=1]/field:data-[filled]:bg-orange-600",
"group-data-[score=2]/field:data-[filled]:bg-yellow-500",
"group-data-[score=3]/field:data-[filled]:bg-lime-500",
"group-data-[score=4]/field:data-[filled]:bg-green-600"
].join(" ");
export function PasswordField({ value, onChange, userInputs, breach: check, error }: PasswordFieldProps) {
const [breach, setBreach] = useState<BreachState>({ status: "idle", count: 0, error: null });
return (
<div className="grid gap-1.5">
<label htmlFor="password" className="text-xs text-neutral-400">Password</label>
<Input
id="password"
type="password"
autoComplete="new-password"
value={value}
onChange={(event) => onChange(event.target.value)}
generate={{ length: 20 }}
strength={{ userInputs }}
// The check is a prop, so the network request is a decision this file makes
// rather than one the field takes on its own.
breach={check}
onBreachChange={setBreach}
wrapperProps={{ className: "group/field grid gap-1.5" }}
fieldProps={{
className: "flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-900 px-3 focus-within:border-neutral-400 group-data-[breached]/field:border-red-600"
}}
className="min-w-0 flex-1 border-0 bg-transparent py-2.5 text-sm text-neutral-100 outline-none"
classNames={{
actions: "inline-flex gap-0.5",
action: "grid h-7 w-7 place-items-center rounded-md text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100 aria-pressed:text-amber-400 disabled:opacity-50",
strength: {
track: "grid grid-cols-5 gap-1",
segment: SEGMENT,
label: "m-0 text-xs text-neutral-400",
warning: "m-0 text-xs text-amber-400"
}
}}
>
{/* Whatever a breach means here is this form's decision, so this form makes
it. The field reports the count and stops. */}
{breach.status === "breached" && (
<p className="m-0 text-xs text-red-500">
This password has appeared in {breach.count.toLocaleString()} breaches. Please pick another.
</p>
)}
{error && <p className="m-0 text-xs text-red-500">{error}</p>}
</Input>
</div>
);
}/* A starting point for the field, yours to edit. The primitive renders the structure and
publishes its state through data-* attributes; every colour below is this file's. */
[data-enigma-input-root] { display: grid; gap: 0.375rem; }
[data-enigma-input-field] {
display: flex; align-items: center; gap: 0.375rem;
padding: 0 0.75rem;
background: #171717; border: 1px solid #404040; border-radius: 0.5rem;
}
[data-enigma-input-field]:focus-within { border-color: #a3a3a3; }
[data-enigma-input] {
flex: 1; min-width: 0; padding: 0.625rem 0;
font-size: 0.875rem; color: #f5f5f5;
background: transparent; border: 0; outline: none;
}
/* A breached password is worth showing on the field itself, not only in the message. */
[data-enigma-input-root][data-breached] [data-enigma-input-field] { border-color: #dc2626; }
[data-enigma-input-actions] { display: inline-flex; gap: 0.125rem; }
[data-enigma-input-actions][data-position="start"] { order: -1; }
[data-enigma-input-action] {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem;
color: #a3a3a3; background: none; border: 0; border-radius: 0.375rem;
cursor: pointer; font-size: 0.9375rem;
}
[data-enigma-input-action]:hover { color: #f5f5f5; background: #262626; }
[data-enigma-input-action][aria-pressed="true"] { color: #fbbf24; }
[data-enigma-input-action]:disabled { opacity: 0.5; cursor: default; }
/* The meter. Five bars, one per score, red through green - the palette lives here because
a headless package has no business choosing what "strong" looks like on your brand. */
[data-enigma-password-strength] { display: grid; gap: 0.25rem; }
[data-enigma-password-strength-track] { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.25rem; }
[data-enigma-password-strength-segment] {
height: 0.25rem; border-radius: 999px; background: #262626;
transition: background-color 120ms ease-out;
}
[data-score="0"] [data-enigma-password-strength-segment][data-filled] { background: #dc2626; }
[data-score="1"] [data-enigma-password-strength-segment][data-filled] { background: #ea580c; }
[data-score="2"] [data-enigma-password-strength-segment][data-filled] { background: #eab308; }
[data-score="3"] [data-enigma-password-strength-segment][data-filled] { background: #84cc16; }
[data-score="4"] [data-enigma-password-strength-segment][data-filled] { background: #16a34a; }
[data-enigma-password-strength-label] { margin: 0; font-size: 0.75rem; color: #a3a3a3; }
[data-enigma-password-strength-warning] { margin: 0; font-size: 0.75rem; color: #fbbf24; }
/* An empty field is not a bad password: say nothing until there is something to say. */
[data-enigma-password-strength][data-empty] [data-enigma-password-strength-label] { visibility: hidden; }Generating a password
Off by default. Switch it on for a registration or change-password form, where the visitor has no password yet, and leave it off on a sign-in, where offering to invent one is noise.
<Input type="password" autoComplete="new-password" generate={{ length: 24 }} />
The value is written the way a keystroke writes it, so it reaches a controlled field, an
uncontrolled one and a form library alike - onChange fires exactly as if it had been
typed. Characters come from crypto.getRandomValues, drawn by rejection rather than
% alphabet.length, which is biased. Where there is no CSPRNG it throws instead of falling
back, because a generator that quietly produces predictable passwords is worse than one
that refuses.
| Prop | Default | |
|---|---|---|
generate |
false |
true, or GeneratePasswordOptions |
length |
20 |
Long beats clever: length is the only term that scales |
excludeAmbiguous |
false |
Drops I l 1 O 0, for a password that gets typed by hand |
revealOnGenerate |
true |
A password nobody can read is one nobody can write down |
copyOnGenerate |
false |
The clipboard is shared with every app on the machine |
Strength
strength renders the meter under the field: five bars, a label, and the top warning.
<Input
type="password"
autoComplete="new-password"
strength={{ userInputs: [email, name] }}
onStrengthChange={(report) => setScore(report.score)}
/>
userInputs is the check no character-class rule makes. Ada@example.com1! has four
character classes and sixteen characters, passes every policy ever written, and is the first
thing anyone looking at the sign-up form would try.
The component picks no colours: it puts data-score on the root and data-filled on each
segment, and the recipe above turns that into red through green. The score itself is an
estimate and the bands are a convention rather than a measurement - swap the estimator for
zxcvbn where the number has to mean something.
Has it leaked?
breach takes a checker; it is a prop rather than a built-in because it makes a network
request, and that is not a decision a field should take on its own. enigma add password-breach gives you one that never sends the password anywhere.
import { checkPasswordBreach } from "@enigmax/utils";
<Input
type="password"
autoComplete="new-password"
breach={checkPasswordBreach}
onBreachChange={(state) => setBreached(state.status === "breached")}
/>
It is debounced and aborted on the next keystroke, so an answer about a password three
characters old can never land on the current one. The field reports data-breached and a
count, and renders no message: a breach is a warning on one form, a hard block on another,
and each already renders that its own way.
With Zod, validated as you type
The whole thing, the way it actually gets used: one schema, checked on every keystroke, with the field’s own state folded in.
"use client";
import { z } from "zod";
import { useState } from "react";
import { Input } from "@enigmax/primitives/react";
import { checkPasswordBreach } from "@enigmax/utils";
/**
* The schema is the whole rule, and it lives at the OBJECT level - a password field on its
* own cannot see the email three rows up, which is what makes `ada@example.com1!` pass a
* field-level check and fail a real one.
*/
const schema = z.object({
email: z.string().trim().toLowerCase().email("That does not look like an email address."),
password: z.string().min(12, "At least 12 characters.")
}).refine(
({ email, password }) => !password.toLowerCase().includes(email.split("@")[0].toLowerCase()),
{ path: ["password"], message: "Your password cannot contain your email address." }
);
export function Register() {
const [values, setValues] = useState({ email: "", password: "" });
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [breached, setBreached] = useState(false);
// Parsed on every render, so the message appears as the rule starts passing rather
// than on submit. Only shown for fields the visitor has actually left.
const result = schema.safeParse(values);
const errors = result.success ? {} : z.flattenError(result.error).fieldErrors;
const errorFor = (field: keyof typeof values) => (touched[field] ? errors[field]?.[0] : undefined);
return (
<form
noValidate
onSubmit={(event) => {
event.preventDefault();
setTouched({ email: true, password: true });
if (!result.success || breached) return;
// ...
}}
>
<Input
type="email"
autoComplete="email"
value={values.email}
onChange={(event) => setValues({ ...values, email: event.target.value })}
onBlur={() => setTouched({ ...touched, email: true })}
aria-invalid={Boolean(errorFor("email"))}
>
{errorFor("email") && <p className="error">{errorFor("email")}</p>}
</Input>
<Input
type="password"
autoComplete="new-password"
value={values.password}
onChange={(event) => setValues({ ...values, password: event.target.value })}
onBlur={() => setTouched({ ...touched, password: true })}
aria-invalid={Boolean(errorFor("password"))}
generate={{ length: 20 }}
strength={{ userInputs: [values.email] }}
breach={checkPasswordBreach}
onBreachChange={(state) => setBreached(state.status === "breached")}
>
{errorFor("password") && <p className="error">{errorFor("password")}</p>}
{breached && <p className="error">This password has appeared in a breach. Please pick another.</p>}
</Input>
<button type="submit" disabled={!result.success || breached}>Create account</button>
</form>
);
}
Two things worth copying from it. The same schema runs again on the server, because a client-side check is a convenience and never a guarantee. And the breach result is kept beside the schema rather than inside it: it arrives asynchronously, so folding it into a synchronous parse would either block the form on a network request or lie about the state while one is in flight.
Replacing a button, or adding your own
The reveal and the generator are ordinary actions. Passing one with the same name replaces
it; any other name is added.
<Input
type="password"
actions={[
{ name: "reveal", label: "Show", icon: <MyEye />, pressed: shown, onSelect: toggle },
{ name: "caps", label: "Caps lock is on", icon: <MyCaps />, visible: capsOn, onSelect: () => {} }
]}
/>
position="start" moves them to the other end of the field. An action whose visible is
false is removed from the DOM rather than hidden - the hidden attribute works through a UA
display: none rule, and any styling you write for these buttons beats it.
Styling hooks
Nothing here ships a colour.
| Attribute | On |
|---|---|
[data-enigma-input-root] |
The wrapper - carries data-revealed, data-breached, data-score |
[data-enigma-input-field] |
The row holding the field and its buttons |
[data-enigma-input] |
The <input> itself |
[data-enigma-input-actions] |
The button container - carries data-position |
[data-enigma-input-action="reveal"] |
One button, by name. aria-pressed tracks the state |
[data-enigma-password-strength] |
The meter - data-empty while there is nothing to score |
[data-enigma-password-strength-segment] |
One bar. data-filled when it is lit |
In Tailwind, the parts you cannot reach with a className take one through classNames,
and a segment colours itself from the score on the root:
<Input
wrapperProps={{ className: "group/field grid gap-1.5" }}
classNames={{
action: "grid h-7 w-7 place-items-center rounded-md text-neutral-400 hover:text-neutral-100",
strength: { segment: "h-1 rounded-full bg-neutral-800 group-data-[score=4]/field:data-[filled]:bg-green-600" }
}}
/>