One component, keyed on type, the way HTML is: type is an attribute of a single element,
so password, search, email, number and the rest are the same field with different
props. 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 is one prop, and off until you ask for it.
Customize
Change the type and watch the controls change with it: the props a field has depend on what kind of field it is, so this panel offers exactly what TypeScript would let you write. Type into it, generate a password, search the list - 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. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.
enigma add inputInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.
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 and distance below is this file's. The custom properties at the top are the
* whole API - override them on `:root` and you have a different field without touching a
* selector, which is the same arrangement the toast and the palette use.
*/
:root {
--enigma-input-bg: #171717;
--enigma-input-border: #404040;
--enigma-input-border-focus: #a3a3a3;
--enigma-input-text: #f5f5f5;
--enigma-input-muted: #a3a3a3;
--enigma-input-radius: 0.5rem;
--enigma-input-font-size: 0.875rem;
--enigma-input-padding: 0 0.75rem;
--enigma-input-danger: #dc2626;
--enigma-input-accent: #fbbf24;
/* The meter, worst to best. A headless package has no business deciding what "strong"
looks like on your brand, so the five bands are here rather than in the component. */
--enigma-strength-empty: #262626;
--enigma-strength-0: #dc2626;
--enigma-strength-1: #ea580c;
--enigma-strength-2: #eab308;
--enigma-strength-3: #84cc16;
--enigma-strength-4: #16a34a;
}
@media (prefers-color-scheme: light) {
:root {
--enigma-input-bg: #ffffff;
--enigma-input-border: #d4d4d4;
--enigma-input-border-focus: #737373;
--enigma-input-text: #171717;
--enigma-input-muted: #737373;
--enigma-strength-empty: #e5e5e5;
}
}
[data-enigma-input-root] { display: grid; gap: 0.375rem; }
[data-enigma-input-field] {
display: flex; align-items: center; gap: 0.375rem;
padding: var(--enigma-input-padding);
background: var(--enigma-input-bg);
border: 1px solid var(--enigma-input-border);
border-radius: var(--enigma-input-radius);
}
[data-enigma-input-field]:focus-within { border-color: var(--enigma-input-border-focus); }
[data-enigma-input] {
flex: 1; min-width: 0; padding: 0.625rem 0;
font-size: var(--enigma-input-font-size); color: var(--enigma-input-text);
background: transparent; border: 0; outline: none;
}
/* The platform's own clear button, gone. A search field here already renders one - so
WebKit draws a SECOND cross next to it, and only in WebKit, which makes it a duplicate
half the visitors see and the other half never do. */
[data-enigma-input]::-webkit-search-cancel-button,
[data-enigma-input]::-webkit-search-decoration,
[data-enigma-input]::-webkit-search-results-button {
-webkit-appearance: none; appearance: none; display: 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: var(--enigma-input-danger); }
[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: var(--enigma-input-muted); background: none; border: 0; border-radius: 0.375rem;
cursor: pointer; font-size: 0.9375rem;
}
[data-enigma-input-action]:hover { color: var(--enigma-input-text); background: color-mix(in srgb, var(--enigma-input-border) 45%, transparent); }
[data-enigma-input-action][aria-pressed="true"] { color: var(--enigma-input-accent); }
[data-enigma-input-action]:disabled { opacity: 0.5; cursor: default; }
/* The meter. Five bars, one per score, red through green. */
[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: var(--enigma-strength-empty);
transition: background-color 120ms ease-out;
}
[data-score="0"] [data-enigma-password-strength-segment][data-filled] { background: var(--enigma-strength-0); }
[data-score="1"] [data-enigma-password-strength-segment][data-filled] { background: var(--enigma-strength-1); }
[data-score="2"] [data-enigma-password-strength-segment][data-filled] { background: var(--enigma-strength-2); }
[data-score="3"] [data-enigma-password-strength-segment][data-filled] { background: var(--enigma-strength-3); }
[data-score="4"] [data-enigma-password-strength-segment][data-filled] { background: var(--enigma-strength-4); }
[data-enigma-password-strength-label] { margin: 0; font-size: 0.75rem; color: var(--enigma-input-muted); }
[data-enigma-password-strength-warning] { margin: 0; font-size: 0.75rem; color: var(--enigma-input-accent); }
/* 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; }Every type, one component
What differs per type is which props EXIST, and that is a discriminated union rather than a
convention: strength on a text field is a compile error, not a prop that quietly does
nothing.
| Type | What it adds |
|---|---|
password |
The reveal toggle, and generate, strength, breach when you ask |
search |
Debounced ranking over items, renderResults, and a clear button |
color |
A swatch that opens the picker: format, alpha, swatches, eyedropper |
email, tel, url, number, date, … |
Nothing but the native field and its own props |
Each type loads its own machinery. The estimator, the breach watcher, the search engine and the colour picker live in their own chunks and arrive when a type that needs them is used - so a form of text and email fields downloads none of them, and the generator is fetched on the first press of its button. Measured: a two-field form is 4.5 KB.
What a type puts ON SCREEN is never in the chunk: the swatch of a colour field is rendered with the field, painted by the value it already holds, and a press on it while the panel’s code is still in flight opens the panel the moment it lands. A control that appears late and swallows the press until it does is the defect this arrangement exists to avoid.
A search field that opens a dialog is not a field - see the command palette for the Ctrl/Cmd+K panel.
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.
It belongs on a form that CREATES a password - registration, a reset, a change-password
screen - and nowhere else. On sign-in it scores a password the visitor already has and cannot
change from that screen, which is noise at best and an accusation at worst; the generator
offers to replace the one they are typing; and a breach check there sends a hash of a real
credential on every attempt. What does belong on sign-in is the same format VALIDATION the
creation form applied, because a password that cannot satisfy the rules it was created under
cannot be the right one - the client refuses to send it and the server never sees the
attempt. autocomplete="current-password" is how a page says which side it is on, and the
fe-new-password-affordance-on-signin guardrail reads exactly that.
<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. The check itself lives in
@enigmax/utils - it renders nothing, so it is equally usable from a server route where the
same rule has to hold - and it 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.
Picking a colour
type="color" is a swatch inside the field, and the swatch opens a panel: a
saturation/brightness square, a hue rail, an optional alpha rail, the value as editable text,
your presets, and the browser’s screen eyedropper where there is one.
<Input
type="color"
value={brand}
onChange={(event) => setBrand(event.target.value)}
swatches={["#3b82f6", "#22c55e", "#f97316", "#ef4444"]}
/>
The field itself is a TEXT input, holding #3b82f6 as a plain string and submitting with
the form like any other. The native type="color" opens a picker drawn by the operating
system: unstylable, different on every platform, with no presets and no alpha, and a value
that can only ever be #rrggbb and cannot be typed or pasted into - which is the fastest way
to enter a colour somebody already has. It is the same trade the select makes.
| Prop | Default | |
|---|---|---|
format |
"hex" |
What the picker WRITES: "hex", "rgb" or "hsl". Every format is still read |
alpha |
false |
The opacity rail, and the alpha channel in the value |
swatches |
none | Presets under the rails: a brand palette, or the last few used |
eyedropper |
true |
Feature-detected. The button is absent where the browser has no EyeDropper |
placement |
"auto" |
Which side the panel opens on. auto flips near the bottom of the window |
styles |
true |
The panel’s stylesheet. See below |
colorLabels |
English | The accessible names, for a UI that is not in English |
Alpha is off on purpose. It turns #3b82f6 into #3b82f680, which is not what a column
typed as a seven-character hex, or a server reading what used to be a native colour input,
expects. Turn it on where the value really carries opacity.
The value, in whichever notation you want to read it
Under the rails is the colour as text, editable, with a button that steps the notation through HEX, RGB and HSL. It is the row the browser’s own picker has, and a picker without it is one you cannot read a value out of, paste a value into, or check against the hex somebody sent you.
Cycling changes what the panel PRINTS and nothing else. format is your contract with your
own storage, so a field written as hex stays hex however the panel is being read; typing
rgb(255, 0, 0) into the readout moves the square and writes #ff0000 into the field.
What it accepts, and what it refuses
Whatever format writes, everything is READ: a pasted rgb(59 130 246 / 50%) in a hex field
is understood rather than rejected, and normalized to the field’s format on blur - not
mid-keystroke, which would fight the caret. Both rgb() syntaxes parse, because the
space-and-slash form is what devtools puts on the clipboard, and named colours resolve
through the DOM instead of a 148-entry table nobody needed shipped.
Two refusals are deliberate. Five and seven hex digits parse to nothing rather than being
padded, because a truncated paste is how you arrive at one and the missing digit would be a
colour nobody chose. oklch() and color() parse to nothing rather than being clipped into
sRGB, because answering with a hex for a colour a hex cannot hold changes the value silently.
The arithmetic is exported from the package root, so a form that validates the string before storing it, or a page that draws its own picker, uses the same parser:
import { parseColor, formatColor } from "@enigmax/primitives";
const rgb = parseColor(input); // null when it is not a colour this can hold
const value = rgb && formatColor(rgb, "hex");
It ships a theme
This is the one place <Input> has a look of its own, for the same reason
the toast and the select do: there is no useful unstyled version of a
gradient someone drags a handle across - a saturation square with no size is nothing at all.
The sheet is injected once and prepended to <head>, so anything your page already has
outranks it. styles={false} opts out, and @enigmax/primitives/color.css is the same sheet
if you would rather import it.
Every colour and distance is a custom property, and the sheet already answers
prefers-color-scheme:
:root {
--enigma-color-panel-bg: #1c1c1c;
--enigma-color-panel-border: #333333;
--enigma-color-panel-width: 13.5rem;
--enigma-color-area-height: 8rem;
--enigma-color-rail-height: 0.625rem;
--enigma-color-thumb: #ffffff;
}
Vanilla has the maths, not the panel
createInput does not grow a colour picker: the square, the rails and their pointer handling
are the React chunk. A page that is not React imports parseColor and the conversions above
and draws its own - the same “no adapter yet” as Vue and Svelte.
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 look, with one exception: the colour picker’s panel, for the reason above.
| 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 |
[data-enigma-color] |
The swatch and its panel - carries data-open while the panel is up |
[data-enigma-color-swatch] |
The button in the field. Its fill is the value the browser resolved |
[data-enigma-color-panel] |
The panel, portaled and placed against the swatch, with [data-side] |
[data-enigma-color-area] |
The saturation and brightness square |
[data-enigma-color-rail="hue"], [data-enigma-color-rail="alpha"] |
The two rails |
[data-enigma-color-value] |
The readout row - the notation button and the text field |
[data-enigma-color-preset] |
One preset from swatches |
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" }
}}
/>