Input

One field for every type. Password gets the reveal, a generator, a meter and a breach check; search gets ranking and a clear button; color gets a picker panel instead of the operating system's; everything else is a plain field.

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

import { Input } from "@enigmax/primitives/react";

export function SignIn() {
    const [password, setPassword] = useState("");

    return (
        <Input
            type="password"
            autoComplete="current-password"
            value={password}
            onChange={(event) => setPassword(event.target.value)}
        />
    );
}

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 input

Installs 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.

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