IconField
Preview
Basic
Loading…
Preview
Code
ts
import IconField from '@/components/form/IconField';src/components/form/IconField.tsx
AI prompt
text
Build an icon field component (a text input with inset icons) in React + TypeScript + Tailwind CSS.
## Look
- Wrapper `relative w-full`; the input uses the standard text-input look, with `pl-8` when there's a left icon and `pr-8` when there's a right one.
- Icon slots: `absolute top-1/2 -translate-y-1/2 flex items-center text-slate-400 dark:text-slate-500`, left at `left-2.5`, right at `right-2.5`, any svg inside forced to 14px.
- `loading` replaces the right slot with a spinning lucide `Loader2`.
## Behaviour
- Slots are `pointer-events-none`, so a click on the icon lands on the input and focuses it; a `<button>` placed in a slot opts back in (`[&_button]:pointer-events-auto`) — that's how a clear/copy/reveal button goes in `iconRight`.
- `loading` sets `aria-busy` on the input.
- Forward the ref and all input props to the `<input>`; `className` goes on the input, `wrapperClassName` on the wrapper.
## API
`iconLeft?: ReactNode`, `iconRight?: ReactNode`, `loading = false`, `wrapperClassName?`, plus every native input prop.
## Demo
A "Search…" field with a Search icon; a read-only share link "https://example.com/share/abc123" with a Globe icon and a small Copy button on the right (`rounded p-0.5 hover:text-slate-600`, focus ring); a "Checking availability…" username field with AtSign on the left and loading on.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { forwardRef } from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/cn';
export interface IconFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** Usually a lucide icon. Sized to 14px for you. */
iconLeft?: React.ReactNode;
/** An icon, or a small `<button>` (clear, copy) — buttons stay clickable. */
iconRight?: React.ReactNode;
/** Replaces `iconRight` with a spinner and sets `aria-busy` on the input. */
loading?: boolean;
/** Classes for the wrapper. `className` goes on the `<input>`, like a plain input. */
wrapperClassName?: string;
}
/**
* A `field-input` with an icon inset on either side.
*
* The icon slots are `pointer-events-none`, so a click on the glass lands on the
* input and focuses it — an icon that swallows the click is a dead patch inside
* the field. A `<button>` placed in a slot opts back in, which is how a clear or
* reveal button goes in `iconRight` without a second component.
*/
const IconField = forwardRef<HTMLInputElement, IconFieldProps>(function IconField(
{ iconLeft, iconRight, loading = false, wrapperClassName, className, ...rest },
ref,
) {
const right = loading ? <Loader2 className="animate-spin" /> : iconRight;
const slot =
'pointer-events-none absolute top-1/2 flex -translate-y-1/2 items-center text-slate-400 dark:text-slate-500 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_button]:pointer-events-auto';
return (
<div className={cn('relative w-full', wrapperClassName)}>
{!!iconLeft && <span className={cn(slot, 'left-2.5')}>{iconLeft}</span>}
<input
ref={ref}
aria-busy={loading || undefined}
{...rest}
className={cn('field-input', !!iconLeft && 'pl-8', !!right && 'pr-8', className)}
/>
{!!right && <span className={cn(slot, 'right-2.5')}>{right}</span>}
</div>
);
});
export default IconField;
Props
| Prop | Type | Default | Description |
|---|---|---|---|
iconLeft | React.ReactNode | — | Usually a lucide icon. Sized to 14px for you. |
iconRight | React.ReactNode | — | An icon, or a small `<button>` (clear, copy) — buttons stay clickable. |
loading | boolean | false | Replaces `iconRight` with a spinner and sets `aria-busy` on the input. |
wrapperClassName | string | — | Classes for the wrapper. `className` goes on the `<input>`, like a plain input. |
Also accepts every prop <input> takes — they are spread onto the root element.