Components
Search
Pill text input + dropdown results panel. Keyboard navigation, clearable, async-ready. Built on a native search input.
Preview#
<ForenSearch
placeholder="Search tokens…"
results={results}
onSelect={(r) => setPicked(r)}
/>Installation#
Same package as the rest of Foren.
bash
npm install @foren/uitsx
import { ForenSearch } from "@foren/ui";Usage#
Pass static results or wire onSearch for async.
tsx
// Static results — built-in substring filter
<ForenSearch
placeholder="Search…"
results={[
{ value: "1", label: "Item one" },
{ value: "2", label: "Item two", description: "Helpful subtitle" },
]}
onSelect={(r) => router.push(r.value)}
/>
// Async / custom filter
<ForenSearch
placeholder="Search…"
onSearch={(q) => myApi.fuzzy(q)}
onSelect={pick}
/>Source#
Drop-in source if you'd rather skip the package.
components/ui/foren-search.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type ForenSearchResult = {
/** Unique value passed back to onSelect. */
value: string;
/** Display label. */
label: React.ReactNode;
/** Optional secondary text below the label. */
description?: React.ReactNode;
/** Optional leading icon. Defaults to a cube. */
icon?: React.ReactNode;
};
export interface ForenSearchProps {
/** Controlled input value. */
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/** Selected option callback. */
onSelect?: (result: ForenSearchResult) => void;
/** Static list of results, or use `onSearch` for async filtering. */
results?: ForenSearchResult[];
/** Override the default substring filter. Return a list given the query. */
onSearch?: (query: string) => ForenSearchResult[];
placeholder?: string;
size?: "md" | "lg";
/** Show a × clear button when value is non-empty. Defaults true. */
clearable?: boolean;
/** Show the results panel even when query is empty. */
alwaysShowResults?: boolean;
className?: string;
containerClassName?: string;
/** Render a custom empty state inside the panel. */
emptyState?: React.ReactNode;
/** Hide built-in result panel — useful when you want to manage UI yourself. */
hideResults?: boolean;
/** aria-label for the input. */
ariaLabel?: string;
disabled?: boolean;
}
const SearchIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
const XIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
const CubeIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
);
export function ForenSearch({
value: controlled,
defaultValue = "",
onChange,
onSelect,
results,
onSearch,
placeholder = "Search…",
size = "md",
clearable = true,
alwaysShowResults = false,
className,
containerClassName,
emptyState,
hideResults = false,
ariaLabel = "Search",
disabled = false,
}: ForenSearchProps) {
const isControlled = controlled !== undefined;
const [internal, setInternal] = React.useState(defaultValue);
const value = isControlled ? controlled! : internal;
const [open, setOpenState] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const [entered, setEntered] = React.useState(false);
const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const setOpen = React.useCallback((next: boolean) => {
setOpenState((prev) => {
if (prev && !next) {
setClosing(true);
if (closeTimer.current) clearTimeout(closeTimer.current);
const ms =
parseFloat(
typeof window !== "undefined"
? getComputedStyle(document.documentElement).getPropertyValue("--dropdown-close-dur")
: ""
) || 150;
closeTimer.current = setTimeout(() => setClosing(false), ms);
} else if (!prev && next) {
setClosing(false);
if (closeTimer.current) clearTimeout(closeTimer.current);
}
return next;
});
}, []);
React.useEffect(() => {
if (open) {
const r = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(r);
}
setEntered(false);
}, [open]);
const [active, setActive] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
const filtered = React.useMemo(() => {
if (onSearch) return onSearch(value);
if (!results) return [];
const q = value.trim().toLowerCase();
if (!q) return alwaysShowResults ? results : [];
return results.filter((r) => {
const haystack = (
(typeof r.label === "string" ? r.label : "") +
" " +
(typeof r.description === "string" ? r.description : "") +
" " +
r.value
).toLowerCase();
return haystack.includes(q);
});
}, [value, results, onSearch, alwaysShowResults]);
// Close on outside click
React.useEffect(() => {
if (!open) return;
function onClick(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
window.addEventListener("mousedown", onClick);
return () => window.removeEventListener("mousedown", onClick);
}, [open]);
React.useEffect(() => setActive(0), [filtered.length]);
function setValue(next: string) {
if (!isControlled) setInternal(next);
onChange?.(next);
}
function commit(r: ForenSearchResult) {
onSelect?.(r);
setValue(typeof r.label === "string" ? r.label : r.value);
setOpen(false);
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
if (!open) setOpen(true);
e.preventDefault();
setActive((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
if (filtered[active]) {
e.preventDefault();
commit(filtered[active]);
}
} else if (e.key === "Escape") {
setOpen(false);
}
}
const showPanel: boolean =
!hideResults && (open || closing) && (filtered.length > 0 || (!!emptyState && value.length > 0));
const heightClass = size === "lg" ? "h-14" : "h-12";
return (
<div ref={rootRef} className={cn("relative w-full", containerClassName)}>
<div
className={cn(
"flex items-center gap-2 rounded-[16px] border transition-[background-color,border-color,box-shadow] duration-150 ease-out px-3.5",
heightClass,
"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)]",
disabled && "bg-[var(--color-foren-bg-button-disabled)] cursor-not-allowed pointer-events-none"
)}
>
<span className="text-[var(--color-foren-text-tertiary)] shrink-0">
<SearchIcon />
</span>
<input
ref={inputRef}
type="search"
role="combobox"
aria-expanded={showPanel}
aria-autocomplete="list"
aria-label={ariaLabel}
value={value}
placeholder={placeholder}
disabled={disabled}
onFocus={() => setOpen(true)}
onChange={(e) => {
setValue(e.target.value);
setOpen(true);
}}
onKeyDown={onKeyDown}
className={cn(
"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 text-[14px] leading-[21px]",
"[&::-webkit-search-cancel-button]:hidden",
className
)}
/>
{clearable && value && !disabled && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setValue("");
inputRef.current?.focus();
}}
className="shrink-0 inline-flex items-center justify-center size-6 rounded-full text-[var(--color-foren-text-tertiary)] hover:text-[var(--color-foren-text-primary)] hover:bg-[var(--color-foren-bg-subtle)] cursor-pointer transition-colors"
>
<XIcon />
</button>
)}
</div>
{showPanel && (
<div
role="listbox"
data-origin="top-left"
className={cn(
"t-dropdown absolute z-50 top-[calc(100%+8px)] left-0 right-0 rounded-[16px] bg-[var(--color-foren-bg-default)] border border-[var(--color-foren-border-muted)] shadow-[var(--shadow-foren-md)] p-1.5 max-h-72 overflow-y-auto",
entered && !closing && "is-open",
closing && "is-closing"
)}
>
{filtered.length === 0 && emptyState && (
<div className="px-3 py-3 text-[12.5px] text-[var(--color-foren-text-tertiary)]">{emptyState}</div>
)}
{filtered.map((r, i) => {
const isActive = i === active;
return (
<button
key={r.value}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(i)}
onClick={() => commit(r)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-left transition-colors",
isActive ? "bg-[var(--color-foren-bg-subtle)]" : "hover:bg-[var(--color-foren-bg-sunken)]"
)}
>
<span className="text-[var(--color-foren-text-tertiary)] shrink-0">
{r.icon ?? <CubeIcon />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-[var(--color-foren-text-primary)] truncate">
{r.label}
</span>
{r.description && (
<span className="block text-[11px] text-[var(--color-foren-text-tertiary)] truncate">
{r.description}
</span>
)}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
Default#
Pill input with leading search icon.
<ForenSearch placeholder="Search…" />With results#
Type to filter. Arrow keys navigate, Enter selects, Esc closes.
<ForenSearch
placeholder="Search tokens…"
results={results}
alwaysShowResults
onSelect={(r) => console.log(r.value)}
/>Interactive#
Live demo — selecting a result reports it back.
const [picked, setPicked] = useState<string | null>(null);
<ForenSearch
placeholder="Search tokens…"
results={tokens}
onSelect={(r) => setPicked(r.value)}
emptyState="No tokens match that query."
/>API#
PropTypeDefault
valuestring
defaultValuestring
onChange(value) => void
onSelect(result) => void
resultsForenSearchResult[]
onSearch(q) => ForenSearchResult[]
placeholderstring
size"md" | "lg"
clearableboolean
alwaysShowResultsboolean
emptyStateReactNode
disabledboolean