Components / Inputs & Forms
Input
Text fields for single-line data entry, including multi-select.
Default
With placeholder
Error state
Username must be at least 3 characters.
Disabled
Multi Select
Default
Select multiple items from a dropdown. Checkboxes toggle individual items. Action labels appear on hover to show context-aware actions.
2 items selected — Hover over items to see action labels. Different actions appear based on selection state.
Keyboard Navigation
Full keyboard support for accessibility and power users.
↑ ↓Navigate between rows
← →Switch checkbox / action focus
Enter / SpaceToggle or execute action
EscClose dropdown
Controlled State
Manage selections programmatically with external controls.
Selected: Analytics
Source 2 files
The exact code behind the live demo above. Fetch it raw at /components/source/input.txt.
InputDemo.tsx
import React from "react";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-8">
<h3 className="text-sm font-bold text-[var(--flux-heading)] mb-3">{title}</h3>
<div className="p-6 rounded border border-[var(--flux-grey-100)] bg-[var(--flux-surface)]">
{children}
</div>
</div>
);
}
const labelClass = "block text-sm font-medium text-[var(--flux-black)] mb-1";
const inputBase =
"w-full px-3 py-2 rounded border border-[var(--flux-grey-200)] bg-[var(--flux-surface)] text-sm text-[var(--flux-black)] focus:border-[var(--flux-primary-400)] focus:ring-1 focus:ring-[var(--flux-primary-300)] outline-none transition-colors";
export default function InputDemo() {
return (
<div>
<Section title="Default">
<div className="max-w-sm">
<label className={labelClass}>Full name</label>
<input type="text" className={inputBase} defaultValue="Jane Doe" />
</div>
</Section>
<Section title="With placeholder">
<div className="max-w-sm">
<label className={labelClass}>Email address</label>
<input
type="email"
className={inputBase}
placeholder="you@example.com"
/>
</div>
</Section>
<Section title="Error state">
<div className="max-w-sm">
<label className={labelClass}>Username</label>
<input
type="text"
className={`${inputBase} !border-[var(--flux-error)] focus:!ring-[var(--flux-error)]`}
defaultValue="ab"
/>
<p className="mt-1 text-xs text-[var(--flux-error)]">
Username must be at least 3 characters.
</p>
</div>
</Section>
<Section title="Disabled">
<div className="max-w-sm">
<label className={labelClass}>Company</label>
<input
type="text"
disabled
className={`${inputBase} opacity-50 cursor-not-allowed bg-[var(--flux-grey-50)]`}
defaultValue="Kaptio"
/>
</div>
</Section>
</div>
);
} MultiSelectDemo.tsx
import { useState, useRef, useEffect, useCallback } from 'react';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h3 className="text-sm font-bold text-[var(--flux-heading)] mb-3">{title}</h3>
{children}
</div>
);
}
interface MultiSelectOption {
id: string;
label: string;
}
interface MultiSelectProps {
options: MultiSelectOption[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className={className}>
<path d="M11.5 4L5.5 10L2.5 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ChevronDown({ className }: { className?: string }) {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className={className}>
<path d="M3.5 5.5L7 9L10.5 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function MultiSelect({ options, selected, onChange, placeholder = 'Select items...' }: MultiSelectProps) {
const [open, setOpen] = useState(false);
const [focusIdx, setFocusIdx] = useState(0);
const [focusCol, setFocusCol] = useState<'checkbox' | 'button'>('checkbox');
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const isSelected = (id: string) => selected.includes(id);
const toggle = useCallback((id: string) => {
onChange(
isSelected(id)
? selected.filter(s => s !== id)
: [...selected, id]
);
}, [selected, onChange]);
const selectOnly = useCallback((id: string) => {
onChange([id]);
}, [onChange]);
const getButtonAction = useCallback((id: string): { label: string; action: () => void } => {
if (selected.length === 0) {
return { label: 'Select Only', action: () => selectOnly(id) };
}
if (selected.length === 1 && isSelected(id)) {
return { label: 'Select All', action: () => onChange(options.map(o => o.id)) };
}
if (isSelected(id)) {
return { label: 'Select Only', action: () => selectOnly(id) };
}
return { label: 'Select Only', action: () => selectOnly(id) };
}, [selected, options, onChange, selectOnly]);
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusIdx(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusIdx(i => Math.max(i - 1, 0));
break;
case 'ArrowLeft':
e.preventDefault();
setFocusCol('checkbox');
break;
case 'ArrowRight':
e.preventDefault();
setFocusCol('button');
break;
case 'Enter':
case ' ':
e.preventDefault();
if (focusCol === 'checkbox') {
toggle(options[focusIdx].id);
} else {
getButtonAction(options[focusIdx].id).action();
}
break;
case 'Escape':
setOpen(false);
break;
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, focusIdx, focusCol, options, toggle, getButtonAction]);
useEffect(() => {
if (!open) return;
function onClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [open]);
useEffect(() => {
if (open && listRef.current) {
const focused = listRef.current.querySelector(`[data-idx="${focusIdx}"]`);
focused?.scrollIntoView({ block: 'nearest' });
}
}, [focusIdx, open]);
const selectedLabels = options.filter(o => selected.includes(o.id)).map(o => o.label);
return (
<div ref={containerRef} className="relative w-72">
<button
type="button"
onClick={() => { setOpen(v => !v); setFocusIdx(0); setFocusCol('checkbox'); }}
className="w-full flex items-center justify-between px-3 py-2 rounded border border-[var(--flux-grey-200)] bg-[var(--flux-surface)] text-sm text-[var(--flux-heading)] hover:border-[var(--flux-grey-300)] transition-colors"
>
<span className={selectedLabels.length === 0 ? 'text-[var(--flux-grey-300)]' : ''}>
{selectedLabels.length === 0
? placeholder
: selectedLabels.length <= 2
? selectedLabels.join(', ')
: `${selectedLabels.length} items selected`
}
</span>
<ChevronDown className={`text-[var(--flux-grey-300)] transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div
ref={listRef}
className="absolute top-full left-0 right-0 mt-1 z-50 rounded border border-[var(--flux-grey-200)] bg-[var(--flux-surface)] shadow-lg overflow-hidden max-h-64 overflow-y-auto"
>
{options.map((option, i) => {
const checked = isSelected(option.id);
const focused = i === focusIdx;
const action = getButtonAction(option.id);
return (
<div
key={option.id}
data-idx={i}
className={`flex items-center gap-0 transition-colors ${focused ? 'bg-[var(--flux-primary-50)]' : 'hover:bg-[var(--flux-grey-50)]'}`}
onMouseEnter={() => setFocusIdx(i)}
>
<button
type="button"
onClick={() => { toggle(option.id); setFocusCol('checkbox'); }}
className={`flex items-center gap-2.5 flex-1 px-3 py-2 text-left transition-colors ${
focused && focusCol === 'checkbox'
? 'outline-none'
: ''
}`}
>
<span className={`
w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border transition-colors
${checked
? 'bg-[var(--flux-primary-600)] border-[var(--flux-primary-600)] text-white'
: 'border-[var(--flux-grey-200)] bg-[var(--flux-surface)]'
}
${focused && focusCol === 'checkbox' ? 'ring-2 ring-[var(--flux-primary-300)] ring-offset-1' : ''}
`}>
{checked && <CheckIcon />}
</span>
<span className="text-sm text-[var(--flux-heading)]">{option.label}</span>
</button>
<button
type="button"
onClick={() => { action.action(); setFocusCol('button'); }}
className={`
px-3 py-2 text-[11px] font-bold text-[var(--flux-primary-400)] whitespace-nowrap
opacity-0 transition-opacity
${focused ? 'opacity-100' : 'group-hover:opacity-100'}
${focused && focusCol === 'button' ? 'opacity-100 underline' : ''}
`}
>
{action.label}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
const actionOptions: MultiSelectOption[] = [
{ id: 'analytics', label: 'Analytics' },
{ id: 'marketing', label: 'Marketing' },
{ id: 'sales', label: 'Sales' },
{ id: 'support', label: 'Support' },
{ id: 'engineering', label: 'Engineering' },
{ id: 'design', label: 'Design' },
];
const featureOptions: MultiSelectOption[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'reports', label: 'Reports' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'api-access', label: 'API Access' },
{ id: 'sso', label: 'Single Sign-On' },
{ id: 'audit-log', label: 'Audit Log' },
{ id: 'webhooks', label: 'Webhooks' },
{ id: 'custom-roles', label: 'Custom Roles' },
];
export default function MultiSelectDemo() {
const [selected1, setSelected1] = useState<string[]>(['analytics', 'sales']);
const [selected2, setSelected2] = useState<string[]>([]);
const [selected3, setSelected3] = useState<string[]>(['analytics']);
return (
<div>
<Section title="Default">
<p className="text-sm text-[var(--flux-black)] mb-4">
Select multiple items from a dropdown. Checkboxes toggle individual items. Action labels appear on hover to show context-aware actions.
</p>
<MultiSelect
options={actionOptions}
selected={selected1}
onChange={setSelected1}
placeholder="Select departments..."
/>
{selected1.length > 0 && (
<p className="mt-3 text-xs text-[var(--flux-black)]">
<span className="font-bold text-[var(--flux-heading)]">{selected1.length} item{selected1.length !== 1 ? 's' : ''} selected</span>
{' '}— Hover over items to see action labels. Different actions appear based on selection state.
</p>
)}
</Section>
<Section title="Keyboard Navigation">
<p className="text-sm text-[var(--flux-black)] mb-4">
Full keyboard support for accessibility and power users.
</p>
<div className="mb-4">
<MultiSelect
options={featureOptions}
selected={selected2}
onChange={setSelected2}
placeholder="Select features..."
/>
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ keys: '↑ ↓', desc: 'Navigate between rows' },
{ keys: '← →', desc: 'Switch checkbox / action focus' },
{ keys: 'Enter / Space', desc: 'Toggle or execute action' },
{ keys: 'Esc', desc: 'Close dropdown' },
].map(item => (
<div key={item.keys} className="flex items-start gap-2 text-xs text-[var(--flux-black)]">
<kbd className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-[var(--flux-grey-100)] bg-[var(--flux-grey-50)] text-[var(--flux-heading)] whitespace-nowrap flex-shrink-0">
{item.keys}
</kbd>
<span>{item.desc}</span>
</div>
))}
</div>
</Section>
<Section title="Controlled State">
<p className="text-sm text-[var(--flux-black)] mb-4">
Manage selections programmatically with external controls.
</p>
<div className="flex items-start gap-4">
<MultiSelect
options={actionOptions}
selected={selected3}
onChange={setSelected3}
placeholder="Select departments..."
/>
<div className="flex flex-wrap gap-1.5 pt-1">
<button
onClick={() => setSelected3([])}
className="text-[11px] font-bold px-2.5 py-1 rounded border border-[var(--flux-grey-100)] text-[var(--flux-black)] hover:text-[var(--flux-heading)] hover:border-[var(--flux-grey-200)] transition-colors"
>
Clear All
</button>
<button
onClick={() => setSelected3(actionOptions.filter(o => ['analytics', 'sales', 'support'].includes(o.id)).map(o => o.id))}
className="text-[11px] font-bold px-2.5 py-1 rounded border border-[var(--flux-grey-100)] text-[var(--flux-black)] hover:text-[var(--flux-heading)] hover:border-[var(--flux-grey-200)] transition-colors"
>
Core Features
</button>
<button
onClick={() => setSelected3(actionOptions.filter(o => ['engineering', 'design', 'marketing'].includes(o.id)).map(o => o.id))}
className="text-[11px] font-bold px-2.5 py-1 rounded border border-[var(--flux-grey-100)] text-[var(--flux-black)] hover:text-[var(--flux-heading)] hover:border-[var(--flux-grey-200)] transition-colors"
>
Creative Teams
</button>
</div>
</div>
{selected3.length > 0 && (
<p className="mt-3 text-xs text-[var(--flux-black)]">
Selected: <span className="font-bold text-[var(--flux-heading)]">{actionOptions.filter(o => selected3.includes(o.id)).map(o => o.label).join(', ')}</span>
</p>
)}
</Section>
</div>
);
}