Base components
Input
A text field with a label, a hint and an optional help icon, whose border eases on hover, shows the focus ring at once, and turns red with its hint when the value is not valid.
Live preview
Loading preview
A sign-up form with Email (leading mail icon, help icon), Website (leading text https://), Phone (leading country select), Password (show and hide) and a disabled Company field. Typing an email without @ and leaving the field shows the error. Replay clears the form and types an invalid email, then a valid one.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add inputUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Input } from './input';
const EMAIL_HINT = 'We only use this to send receipts.';
const EMAIL_ERROR = 'Enter an email address with an @ sign.';
const TYPE_MS = 70;
function MailIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.5 5.833 9.05 10.42c.53.37.795.556 1.083.627.255.064.522.064.777 0 .288-.071.553-.257 1.083-.627L18.5 5.833M5.5 16.667h9c1.4 0 2.1 0 2.635-.273a2.5 2.5 0 0 0 1.093-1.092c.272-.535.272-1.235.272-2.635V7.333c0-1.4 0-2.1-.272-2.635a2.5 2.5 0 0 0-1.093-1.092C16.6 3.333 15.9 3.333 14.5 3.333h-9c-1.4 0-2.1 0-2.635.273a2.5 2.5 0 0 0-1.093 1.092C1.5 5.233 1.5 5.933 1.5 7.333v5.334c0 1.4 0 2.1.272 2.635a2.5 2.5 0 0 0 1.093 1.092c.535.273 1.235.273 2.635.273Z" />
</svg>
);
}
function CountrySelect() {
return (
<span className="relative flex items-center">
<select
aria-label="Country code"
defaultValue="US"
className="cursor-pointer appearance-none bg-transparent pe-5 text-tertiary outline-none focus-visible:underline"
>
<option value="US">US</option>
<option value="CA">CA</option>
<option value="GB">GB</option>
<option value="ID">ID</option>
</select>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute end-0 size-4 text-fg-tertiary"
>
<path d="m4 6 4 4 4-4" />
</svg>
</span>
);
}
const hasAt = (email: string) => email === '' || email.includes('@');
export default function InputDemo({ replay = 0 }: { replay?: number }) {
const [email, setEmail] = React.useState('');
const [checked, setChecked] = React.useState(false);
const invalid = checked && !hasAt(email);
React.useEffect(() => {
if (replay === 0) return;
setEmail('');
setChecked(false);
// Types a wrong address, checks it, then fixes it.
const script = [...'olivia.ganisigit.com'].map((_, i, all) => all.slice(0, i + 1).join(''));
const fixed = 'olivia@ganisigit.com';
const timers: number[] = [];
script.forEach((text, i) => {
timers.push(window.setTimeout(() => setEmail(text), (i + 1) * TYPE_MS));
});
const done = (script.length + 1) * TYPE_MS;
timers.push(window.setTimeout(() => setChecked(true), done));
timers.push(window.setTimeout(() => setEmail(fixed), done + 1400));
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<form className="grid w-full max-w-md gap-5" onSubmit={(event) => event.preventDefault()} noValidate>
<Input
label="Email"
type="email"
required
autoComplete="email"
placeholder="olivia@ganisigit.com"
helpText="Use a work or personal address."
iconLeading={<MailIcon />}
value={email}
onValueChange={(next) => {
setEmail(next);
if (hasAt(next)) setChecked(false);
}}
onBlur={() => setChecked(true)}
invalid={invalid}
hint={invalid ? EMAIL_ERROR : EMAIL_HINT}
/>
<Input label="Website" type="url" leadingText="https://" placeholder="www.ganisigit.com" />
<Input label="Phone number" type="tel" leading={<CountrySelect />} placeholder="+1 (555) 000-0000" />
<Input
label="Password"
type="password"
autoComplete="new-password"
defaultValue="lunia-motion"
hint="Use at least 8 characters."
/>
<Input label="Company" defaultValue="Lunia Labs" disabled />
</form>
);
}
API
Parts: root (field), label with required mark (*), help icon at the end of the field (opens a tooltip; the error icon takes its place when invalid), control: leading text (joined outside the border), leading icon, leading select (inside the border), input, error icon, trailing select (inside the border), password button, trailing addon (joined outside the border), hint text (becomes the error text when invalid).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | Controlled value. |
| defaultValue | string | "" | Value when uncontrolled. |
| type | "text" | "email" | "password" | "search" | "tel" | "url" | "number" | "text" | Native input type. password adds the Show password button. |
| size | "sm" | "md" | "lg" | "md" | 40, 44 or 48px tall. |
| label | string | undefined | Label above the field. Without it, pass aria-label. |
| placeholder | string | undefined | Example value shown while empty. Not a replacement for the label. |
| hint | string | undefined | Help line under the field. When invalid, pass the error text here. |
| helpText | string | undefined | Shows the help icon at the end of the field (Figma) with this text in a tooltip. |
| leadingText | string | undefined | Text joined to the start of the field with its own border, for example https:// (Figma Leading text). |
| required | boolean | false | Native required; shows the * after the label. |
| invalid | boolean | false | Error border, error icon and a red hint (Figma Destructive). |
| disabled | boolean | false | Turns the field off, with the Figma Disabled look. |
| readOnly | boolean | false | Value can be read and copied but not changed. |
| name | string | undefined | Form field name. |
| autoComplete | string | undefined | Native autocomplete hint, for example email or current-password. |
| Event | Description |
|---|---|
| valueChange | The value changed while typing. Payload: the new value. |
| focus | The input got focus. |
| blur | The input lost focus. |
| Slot | Description |
|---|---|
| iconLeading | Icon before the input, 20px (Figma Icon leading). |
| leading | Addon at the start, inside the border, for example a small select (Leading dropdown). |
| trailing | Addon at the end, inside the border, for example a small select (Trailing dropdown). |
| trailingAddon | Addon joined to the end of the field with its own border, for example a Button (Trailing button). |
| hint | Rich hint content, replaces the hint prop. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| INP1 | hover the field | control border | border-color | border-primary → border-control_hover | duration-sm (200)ease-out | Swaps at once |
| INP2 | focus | control | border-color, box-shadow | border-primary, shadow-xs → border-brand, focus-ring-shadow-xs | duration-2xs (120)ease-out | Shows at once |
| INP3 | invalid -> true | control border, hint | border-color, color | border-primary, text-tertiary → border-error, text-error-primary | duration-sm (200)ease-out | Swaps at once |
| INP4 | invalid -> true | error icon | opacity, scale | 0, scale-swap → 1, 1 | duration-md (260)ease-out | Shows at once |
| INP5 | hint text changes (for example to an error) | hint | opacity, translate y | 0, -distance-rise → 1, 0 | duration-sm (200)ease-out | Swaps at once |
| INP6 | Show password or Hide password pressed | password button icon (eye and eye off) | opacity, scale, blur | 1, 1, 0 → 0, scale-swap, blur-swap (the new icon does the reverse) | duration-md (260)ease-out | Swaps at once |
| INP7 | hover the help icon or the password button | icon | color | fg-tertiary → fg-tertiary_hover | duration-sm (200)ease-out | Swaps at once |
Accessibility
- A native input inside a field primitive: the label is linked with for and id, the hint with aria-describedby, and invalid sets aria-invalid.
- The required mark * is aria-hidden; required is announced from the native attribute.
- The help icon is a button named "More information" that opens the tooltip on hover, focus and tap; its text is also linked to the input with aria-describedby.
- The password button is a native button with aria-pressed, named "Show password" or "Hide password", and keeps focus when the icon swaps.
- Error text says what happened and what to do, for example "Enter an email address with an @ sign."; color is never the only signal (the error icon and text are there too).
- The help icon is 16px: its button has a 24 x 24px tap area (WCAG 2.5.8).
- Reduced motion: borders, icons and hints change at once.