Lunia DS

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.

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 input

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
valuestringundefinedControlled value.
defaultValuestring""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.
labelstringundefinedLabel above the field. Without it, pass aria-label.
placeholderstringundefinedExample value shown while empty. Not a replacement for the label.
hintstringundefinedHelp line under the field. When invalid, pass the error text here.
helpTextstringundefinedShows the help icon at the end of the field (Figma) with this text in a tooltip.
leadingTextstringundefinedText joined to the start of the field with its own border, for example https:// (Figma Leading text).
requiredbooleanfalseNative required; shows the * after the label.
invalidbooleanfalseError border, error icon and a red hint (Figma Destructive).
disabledbooleanfalseTurns the field off, with the Figma Disabled look.
readOnlybooleanfalseValue can be read and copied but not changed.
namestringundefinedForm field name.
autoCompletestringundefinedNative autocomplete hint, for example email or current-password.
EventDescription
valueChangeThe value changed while typing. Payload: the new value.
focusThe input got focus.
blurThe input lost focus.
SlotDescription
iconLeadingIcon before the input, 20px (Figma Icon leading).
leadingAddon at the start, inside the border, for example a small select (Leading dropdown).
trailingAddon at the end, inside the border, for example a small select (Trailing dropdown).
trailingAddonAddon joined to the end of the field with its own border, for example a Button (Trailing button).
hintRich 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
INP1hover the fieldcontrol borderborder-colorborder-primary → border-control_hoverduration-sm (200)ease-outSwaps at once
INP2focuscontrolborder-color, box-shadowborder-primary, shadow-xs → border-brand, focus-ring-shadow-xsduration-2xs (120)ease-outShows at once
INP3invalid -> truecontrol border, hintborder-color, colorborder-primary, text-tertiary → border-error, text-error-primaryduration-sm (200)ease-outSwaps at once
INP4invalid -> trueerror iconopacity, scale0, scale-swap → 1, 1duration-md (260)ease-outShows at once
INP5hint text changes (for example to an error)hintopacity, translate y0, -distance-rise → 1, 0duration-sm (200)ease-outSwaps at once
INP6Show password or Hide password pressedpassword button icon (eye and eye off)opacity, scale, blur1, 1, 0 → 0, scale-swap, blur-swap (the new icon does the reverse)duration-md (260)ease-outSwaps at once
INP7hover the help icon or the password buttoniconcolorfg-tertiary → fg-tertiary_hoverduration-sm (200)ease-outSwaps 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.