Components
Tag
A selectable chip for filters and segmented controls. Two sizes, three states (unselected, selected, destructive). Built-in spring press, optional leading/trailing icons.
Preview#
<ForenTag state="selected" leadingIcon={<Cube />}>Tag</ForenTag>
<ForenTag>Tag</ForenTag>
<ForenTag state="destructive">Tag</ForenTag>Installation#
Same package as the rest of Foren.
bash
npm install @foren/uitsx
import { ForenTag } from "@foren/ui";Usage#
Controlled selection via the `selected` prop.
tsx
const [active, setActive] = useState<string>("all");
<ForenTag selected={active === "all"} onClick={() => setActive("all")}>
All
</ForenTag>
<ForenTag selected={active === "design"} onClick={() => setActive("design")}>
Design
</ForenTag>Source#
Drop-in source if you'd rather skip the package.
components/ui/foren-tag.tsx
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const tagVariants = cva(
"inline-flex items-center justify-center rounded-full whitespace-nowrap select-none outline-none cursor-pointer will-change-transform transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.97] active:duration-75 disabled:cursor-not-allowed disabled:pointer-events-none disabled:active:scale-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-foren-bg-default)] focus-visible:ring-[var(--color-foren-border-focus)] [&_svg]:shrink-0 [&_svg]:pointer-events-none",
{
variants: {
state: {
unselected:
"bg-[var(--color-foren-bg-button-secondary)] text-[var(--color-foren-text-secondary)] font-medium tracking-[0.01em] hover:bg-[var(--color-foren-bg-button-secondary-hover)] hover:text-[var(--color-foren-text-primary)]",
selected:
"bg-[var(--color-foren-bg-button-primary,#1e1711)] text-[var(--color-foren-text-inverse)] font-semibold tracking-[0.0125em] hover:bg-[var(--color-foren-bg-button-primary-hover)]",
destructive:
"bg-[var(--color-foren-bg-error-subtle)] text-[var(--color-foren-error)] font-semibold tracking-[0.0125em] hover:bg-[var(--color-foren-bg-error-subtle-hover)]",
},
size: {
xs: "h-8 gap-1.5 text-[12px] leading-[16px] [&_svg]:size-[14px]",
normal: "h-11 gap-2 text-[14px] leading-[21px] [&_svg]:size-[16px]",
},
padding: {
symmetric: "",
leading: "",
trailing: "",
both: "",
iconOnly: "",
},
},
compoundVariants: [
// xs paddings — tighter on the icon side
{ size: "xs", padding: "symmetric", className: "px-3" },
{ size: "xs", padding: "leading", className: "pl-2 pr-3" },
{ size: "xs", padding: "trailing", className: "pl-3 pr-2" },
{ size: "xs", padding: "both", className: "px-2" },
{ size: "xs", padding: "iconOnly", className: "px-0 aspect-square" },
// normal paddings — more breathing, bigger asymmetry
{ size: "normal", padding: "symmetric", className: "px-4" },
{ size: "normal", padding: "leading", className: "pl-3 pr-4" },
{ size: "normal", padding: "trailing", className: "pl-4 pr-3" },
{ size: "normal", padding: "both", className: "px-3" },
{ size: "normal", padding: "iconOnly", className: "px-0 aspect-square" },
],
defaultVariants: { state: "unselected", size: "normal", padding: "symmetric" },
}
);
export interface ForenTagProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children">,
VariantProps<typeof tagVariants> {
leadingIcon?: React.ReactNode;
trailingIcon?: React.ReactNode;
children?: React.ReactNode;
/** Convenience for filter UIs — true === state:"selected". */
selected?: boolean;
}
export const ForenTag = React.forwardRef<HTMLButtonElement, ForenTagProps>(function ForenTag(
{
className,
state,
size,
selected,
leadingIcon,
trailingIcon,
children,
type = "button",
...props
},
ref
) {
const resolvedState = state ?? (selected ? "selected" : "unselected");
const hasLabel = children != null;
const padding = !hasLabel && (leadingIcon || trailingIcon)
? "iconOnly"
: leadingIcon && trailingIcon
? "both"
: leadingIcon
? "leading"
: trailingIcon
? "trailing"
: "symmetric";
return (
<button
ref={ref}
type={type}
aria-pressed={resolvedState === "selected" || resolvedState === "destructive"}
className={cn(tagVariants({ state: resolvedState, size, padding }), className)}
{...props}
>
{leadingIcon}
{children != null && <span>{children}</span>}
{trailingIcon}
</button>
);
});
export { tagVariants };
States#
Three states — unselected, selected, destructive.
<ForenTag state="unselected">Unselected</ForenTag>
<ForenTag state="selected">Selected</ForenTag>
<ForenTag state="destructive">Destructive</ForenTag>Sizes#
Two sizes — xs (32px) and normal (44px).
<ForenTag size="xs" selected>Size xs</ForenTag>
<ForenTag size="normal" selected>Size normal</ForenTag>With icons#
Pass any ReactNode to leadingIcon / trailingIcon.
<ForenTag selected leadingIcon={<Cube />}>Leading</ForenTag>
<ForenTag selected trailingIcon={<ArrowRight />}>Trailing</ForenTag>
<ForenTag state="destructive" leadingIcon={<Trash />}>Delete</ForenTag>Interactive#
Click to toggle — the component manages its own selected state.
function TagToggle({ children }: { children: ReactNode }) {
const [selected, setSelected] = useState(false);
return (
<ForenTag selected={selected} onClick={() => setSelected((v) => !v)}>
{children}
</ForenTag>
);
}Filter group#
"All" plus multi-select chips. Mutual exclusion handled by the parent.
const options = ["All", "Design", "Code", "Spec", "Research"];
const [selected, setSelected] = useState<Set<string>>(new Set(["All"]));
return options.map((o) => (
<ForenTag
key={o}
selected={selected.has(o)}
onClick={() => toggle(o)}
>
{o}
</ForenTag>
));Full matrix#
Every state at every size.
xs
normal
API#
PropTypeDefault
state"unselected" | "selected" | "destructive"
selectedboolean (shorthand for state)
size"xs" | "normal"
leadingIconReactNode
trailingIconReactNode
disabledboolean
…restButtonHTMLAttributes<HTMLButtonElement>