Input
Single-line text field with label, leading/trailing icons, and validation states. Sized in medium and large. Built on native <input> with full keyboard, a11y, and form support.
Preview#
Press Enter to submit.
<ForenInput label="Email" placeholder="you@foren.design" leadingIcon={<At />} />
<ForenInput label="Search" placeholder="Search tokens…" leadingIcon={<Search />} description="Press Enter to submit." />Installation#
Same package as the rest of Foren.
npm install @foren/uiimport { ForenInput, ForenPasswordInput } from "@foren/ui";Usage#
Spread native input attributes. Controlled or uncontrolled.
// Uncontrolled
<ForenInput
label="Full name"
name="name"
placeholder="Ada Lovelace"
/>
// Controlled
const [value, setValue] = useState("");
<ForenInput
label="Email"
value={value}
onChange={(e) => setValue(e.target.value)}
type="email"
/>Source#
Drop-in source if you'd rather skip the package.
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const fieldVariants = cva(
"flex items-center gap-2 rounded-[16px] border transition-[background-color,border-color,box-shadow] duration-150 ease-out [&_svg]:shrink-0 [&_svg]:pointer-events-none",
{
variants: {
size: {
md: "h-12 px-3 [&_svg]:size-5",
lg: "h-14 px-3.5 [&_svg]:size-6",
},
status: {
default:
"bg-[var(--color-foren-bg-subtle)] border-transparent hover:border-[var(--color-foren-border-default)] focus-within:bg-[var(--color-foren-bg-default)] focus-within:border-[var(--color-foren-border-focus)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-foren-border-focus)_18%,transparent)]",
error:
"bg-[var(--color-foren-bg-default)] border-[var(--color-foren-error,#c62828)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-foren-error,#c62828)_18%,transparent)]",
success:
"bg-[var(--color-foren-bg-default)] border-[var(--color-foren-success)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-foren-success)_18%,transparent)]",
},
disabled: {
true: "bg-[var(--color-foren-bg-button-disabled)] border-transparent cursor-not-allowed pointer-events-none",
false: "",
},
},
defaultVariants: { size: "md", status: "default", disabled: false },
}
);
const inputVariants = cva(
"flex-1 min-w-0 bg-transparent outline-none font-medium tracking-[0.01em] text-[var(--color-foren-text-primary)] placeholder:text-[var(--color-foren-text-disabled)] disabled:cursor-not-allowed disabled:text-[var(--color-foren-text-disabled)]",
{
variants: {
size: {
md: "text-[14px] leading-[21px]",
lg: "text-[16px] leading-[24px]",
},
},
defaultVariants: { size: "md" },
}
);
const iconColor = "text-[var(--color-foren-text-tertiary)]";
export interface ForenInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "prefix">,
Omit<VariantProps<typeof fieldVariants>, "status" | "disabled"> {
label?: React.ReactNode;
/** Helper text shown under the field when no error/success message. */
description?: React.ReactNode;
/** Error message — also flips status to error. */
error?: React.ReactNode;
/** Success message — also flips status to success. */
success?: React.ReactNode;
leadingIcon?: React.ReactNode;
trailingIcon?: React.ReactNode;
/** Wrapper className (applies to outer label element). */
containerClassName?: string;
}
const EyeIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const EyeOffIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<path d="M2 2l20 20" />
</svg>
);
export interface ForenPasswordInputProps extends Omit<ForenInputProps, "type" | "trailingIcon"> {}
export const ForenPasswordInput = React.forwardRef<HTMLInputElement, ForenPasswordInputProps>(
function ForenPasswordInput({ ...props }, ref) {
const [visible, setVisible] = React.useState(false);
return (
<ForenInput
ref={ref}
type={visible ? "text" : "password"}
trailingIcon={
<button
type="button"
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
onClick={() => setVisible((v) => !v)}
className="pointer-events-auto inline-flex items-center justify-center cursor-pointer text-[var(--color-foren-text-tertiary)] hover:text-[var(--color-foren-text-primary)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-foren-border-focus)] rounded-sm"
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
{...props}
/>
);
}
);
export const ForenInput = React.forwardRef<HTMLInputElement, ForenInputProps>(function ForenInput(
{
className,
containerClassName,
label,
description,
error,
success,
leadingIcon,
trailingIcon,
size = "md",
disabled,
id: idProp,
...props
},
ref
) {
const reactId = React.useId();
const id = idProp ?? reactId;
const status = error ? "error" : success ? "success" : "default";
const messageId = `${id}-msg`;
const message = error ?? success ?? description;
const messageColor =
error
? "text-[var(--color-foren-error,#c62828)]"
: success
? "text-[var(--color-foren-success)]"
: "text-[var(--color-foren-text-tertiary)]";
return (
<div className={cn("flex flex-col gap-2 w-full", containerClassName)}>
{label && (
<label
htmlFor={id}
className="text-[14px] leading-[21px] font-medium tracking-[0.01em] text-[var(--color-foren-text-primary)]"
>
{label}
</label>
)}
<div className={cn(fieldVariants({ size, status, disabled: !!disabled }))}>
{leadingIcon && <span className={cn(iconColor, "inline-flex")}>{leadingIcon}</span>}
<input
ref={ref}
id={id}
disabled={!!disabled}
aria-invalid={!!error}
aria-describedby={message ? messageId : undefined}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{trailingIcon && <span className={cn(iconColor, "inline-flex")}>{trailingIcon}</span>}
</div>
{message && (
<p id={messageId} className={cn("text-[12px] leading-[16px] tracking-[0.01em]", messageColor)}>
{message}
</p>
)}
</div>
);
});
Sizes#
Two sizes — md 48px (default), lg 56px.
<ForenInput size="md" placeholder="48px tall" />
<ForenInput size="lg" placeholder="56px tall" />Label + description#
Label sits above. Description renders below in muted text.
Lowercase letters, numbers, and hyphens. 3–32 characters.
<ForenInput
label="Workspace name"
placeholder="acme-design-system"
description="Lowercase letters, numbers, and hyphens. 3–32 characters."
/>With icons#
Leading and trailing slots accept any ReactNode. Icons inherit currentColor.
<ForenInput placeholder="Search…" leadingIcon={<Search />} />
<ForenInput placeholder="Press →" trailingIcon={<ArrowRight />} />
<ForenInput leadingIcon={<Cube />} trailingIcon={<ArrowRight />} />Error#
Red border + red message. aria-invalid set automatically.
Enter a valid email address.
<ForenInput
label="Email"
defaultValue="not-an-email"
error="Enter a valid email address."
/>Success#
Green border + green confirmation message.
Available.
<ForenInput
label="Workspace name"
defaultValue="foren-design"
success="Available."
/>Disabled#
Muted background, blocked input.
<ForenInput label="Account ID" defaultValue="acct_8c3bcc92" disabled />
<ForenInput placeholder="Disabled empty" disabled />Password#
Reveals on toggle. Tracks own visibility state.
Passwords don't match.
<ForenPasswordInput
label="Password"
placeholder="Min 12 characters"
/>
<ForenPasswordInput
label="Confirm"
error="Passwords don't match."
/>OTP / Code#
Single-character slots — autofocus next on input, backspace goes back, paste fills all slots.
6-digit code sent to your email.
Invalid code. Try again.
We sent a 6-digit code to your email.
<ForenOTP
label="Verification code"
length={6}
description="6-digit code sent to your email."
/>
// Controlled
const [code, setCode] = useState("");
<ForenOTP
length={6}
value={code}
onChange={setCode}
onComplete={(v) => verify(v)}
/>Phone number#
Country dial-code prefix + national number, single rounded shell.
Enter a valid UK number.
<ForenPhoneInput label="Phone number" defaultCountry="NG" placeholder="801 234 5678" />
// Controlled, with combined output
const [value, setValue] = useState("");
<ForenPhoneInput
defaultCountry="NG"
value={value}
onChange={setValue}
onFullChange={(e164) => save(e164)}
/>