Loading...
Loading...
React/TypeScript UI component conventions, atomic hierarchy, classNames utility, variant/size dictionaries, polymorphic `as` prop, icon usage, composition slots, and Tailwind styling. Use when authoring or reviewing any `*.tsx` component.
npx skill4agent add gwitchr/skills components-hierarchy*.tsxclassNamesNotation. Wherever a path appears as,<components>/, or<utils>/, treat it as a placeholder. Resolve to the project's existing paths and aliases (<types>/,src/components,app/components,~/lib/utils, etc.). The shape of the convention matters; the exact location does not.@/types
Precedence. These rules apply only where they don't contradict the project's own enforced rules, ESLint / Biome / Prettier configs,,tsconfig/CLAUDE.md, contributing guides, or stylelint. If a project rule conflicts, the project rule wins; defer to it and (if it makes sense) note the deviation in your PR description.AGENTS.md
Framework / runtime. §1-§10 and §13 are framework-agnostic, they apply to any React + TypeScript + Tailwind setup, including a plain Vite + React SPA with no SSR, no RSC, and no router opinions. §11 (SSR-unsafe components), §12 (multilingual), and parts of §14 (RSC / App Router / Server Components / form actions /performance helpers) are optional and clearly flagged, skip them on SPAs. The core idioms (atomic hierarchy,next/*, variant tokens, dictionaries, polymorphicclassNames, slots, React 19asprop, hooks discipline) are universal.ref
*.tsxas<components>/
atoms/ single-purpose primitives (Button, Input, Text, Card, Badge, ...)
molecules/ small compositions of atoms (ModalDialog, DropdownButton, ...)
organisms/ domain-aware blocks (grouped by domain subfolder)
layouts/ page chrome (NavBar, Footer, SideBar, ...)
sections/ marketing/landing page sections (Hero, Features, ...)atoms ← molecules ← organisms ← layouts ← sectionsPascalCase.tsxButton.ui.tsxindex.ts.tsxorganisms/<domain>/index.ts__tests__/molecules/__tests__organisms/atoms/molecules// Always root-relative; never "../../"
import { Button, Tooltip, type ButtonProps } from "components/atoms";
import { ModalDialog } from "components/molecules";
import { classNames } from "utils";
import { SizeVariants, ColorVariants, type SizeVariant, type ColorVariant } from "types/variants";import { type Foo }classNamesclsx<utils>/classNames.tsexport function classNames(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(" ");
}clsxclassnamesclassNames<utils>""false...SizeDict[size]classNameclassName={classNames(
"inline-flex",
"items-center",
"rounded-lg",
...SizeDict[size],
...ColorDict[variant],
disabled ? "cursor-not-allowed opacity-50" : "",
block ? "w-full" : "",
className // consumer override, always last
)}Exception: A component can use a template literal because its class set is trivial. Preferfor anything non-trivial, hard limit is 4 css classes applied or moreclassNames
<types>/variants.tsconstenumenum--erasableSyntaxOnlyexport const ColorVariants = {
primary: "primary",
secondary:"secondary",
plain: "plain",
outlined: "outlined",
danger: "danger",
warning: "warning",
success: "success",
info: "info"
} as const;
export type ColorVariant = (typeof ColorVariants)[keyof typeof ColorVariants];
export const SizeVariants = {
tiny: "tiny",
small: "small",
base: "base",
medium: "medium",
large: "large"
} as const;
export type SizeVariant = (typeof SizeVariants)[keyof typeof SizeVariants];ColorVariantSizeVariantkeyof typeof ColorVariantsExtract<ColorVariant, "info" | "danger" | "warning" | "success">size = SizeVariants.mediumvariant = ColorVariants.primaryMigrating from? A string-valuedenumobject with literal-union type is a drop-in replacement at all call sites that already usedconst, no consumer changes needed.keyof typeof X
const SizeDict: Record<SizeVariant, string[]> = {
tiny: ["px-3", "py-2", "text-xs"],
small: ["px-3", "py-1"],
base: ["text-sm", "px-5", "py-2.5"],
medium: ["text-sm", "px-5", "py-2.5"],
large: ["px-2", "py-1"]
};
// Example tokens for a dark-mode-only baseline. For a light/dark project,
// pair every surface/text class with a `dark:` counterpart.
const BadgeColorTypeMap: Record<BadgeType, string[]> = {
info: ["bg-blue-900", "text-blue-300"],
dark: ["bg-gray-700", "text-gray-300"],
success: ["bg-green-900", "text-green-300"],
warning: ["bg-yellow-900", "text-yellow-300"],
error: ["bg-red-900", "text-red-300"],
main: ["bg-indigo-600", "text-white"]
};getXStyle()string[]const getButtonColorStyle = (): string[] => {
const baseStyle = group ? baseGroupButtonStyle : baseButtonStyle;
switch (variant) {
case ColorVariants.primary:
return [...baseStyle, "bg-indigo-700", "hover:bg-indigo-900", "focus:ring-indigo-900"];
case ColorVariants.danger:
return [...baseStyle, "bg-red-700", "hover:bg-red-900", "focus:ring-red-900"];
case ColorVariants.plain:
return ["text-white"];
default:
return [...baseStyle, "text-white", "bg-slate-700", "hover:bg-slate-600"];
}
};SizeDictColorDictXColorTypeMapstring[]...SizeDict[size]SCREAMING_CASEbaseXStyleswitch| Prop | Type | Notes |
|---|---|---|
| | Always merged last in |
| | Default |
| | Default |
| | Adds |
| | Adds |
| discriminated literal union | See §7 Polymorphism |
| via | Default rendering slot |
| | Composition slots, see §8 |
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ColorVariant;
size?: SizeVariant;
group?: boolean;
block?: boolean;
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
labelVisible?: boolean;
invalid?: boolean;
inputSize?: SizeVariant;
}...attributes...inputPropsaria-*data-*nameidtype BadgeProps = {
type?: BadgeType;
size?: SizeVariant;
} & (
| { handleRemove: (id: string) => void; badgeId: string; removeTextStyle: string }
| { handleRemove?: never; badgeId?: never; removeTextStyle?: never }
);refrefforwardRefRef<HTMLElement>export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: Ref<HTMLButtonElement>;
variant?: ColorVariant;
size?: SizeVariant;
}
export function Button({
ref,
variant = "primary",
size = SizeVariants.medium,
className,
children,
...rest
}: PropsWithChildren<ButtonProps>) {
return <button ref={ref} {...rest}>{children}</button>;
}forwardRefReadonlyvariant = "primary"size = SizeVariants.mediumbold = falseReadonly<PropsWithChildren<...>>asasElementTypetype CardProps = (
| (ButtonHTMLAttributes<HTMLButtonElement> & { as: "button" })
| (AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" })
| (HTMLAttributes<HTMLElement> & { as: "section" })
) & PropsWithChildren<{
size?: SizeVariant;
variant?: ColorVariant;
// ...other shared props
}>;
export function Card({ as, children, ...otherProps }: CardProps) {
const CardWrapper: ElementType = as ?? "section";
return <CardWrapper {...otherProps}>{children}</CardWrapper>;
}hrefas="a"onClickas="button"as"button" | "a" | "section"ElementType"section""div"as ?? "section"CapitalizedElementType"a"AnchorHTMLAttributes<HTMLAnchorElement>ReactNodechildreninterface ModalDialogProps {
Title?: ReactNode; // header content
Actions?: ReactNode; // footer content
visible: boolean;
// ...
}
<ModalDialog
visible={open}
Title={<h2>Confirm</h2>}
Actions={
<>
<Button variant="plain" onClick={cancel}>Cancel</Button>
<Button variant="danger" onClick={confirm}>Delete</Button>
</>
}
>
Are you sure?
</ModalDialog>TitleActionsTitleElementchildrenTabsTabAccordionAccordionItemuseContextuse()Children.mapcloneElementisFirstisLast<Tab>import { FontAwesomeIcon, type FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
// Use whichever icon package(s) the project licenses.
import { faCheck, faTimes, faSpinner } from "<primary-icon-pkg>";
import { faPlus } from "<secondary-icon-pkg>";
<FontAwesomeIcon icon={faCheck} className="text-green-400" />
<FontAwesomeIcon icon={faSpinner} spin />
<FontAwesomeIcon icon={faTimes} size="lg" />FontAwesomeIconProps["icon"]FontAwesomeIconProps["size"]interface ButtonIconTooltipProps {
icon: FontAwesomeIconProps["icon"];
size?: FontAwesomeIconProps["size"];
}className"text-green-400""text-red-400"color<FontAwesomeIcon icon={faSpinner} spin />spinpulsepulsespin<span className="sr-only">{label}</span><components>/atoms/icons/<svg>styleobjectFitbg-gray-700/800/900text-whitetext-gray-300/400border-gray-600/700dark:dark:indigoemeraldblueslateindigotheme.extend.colors.<name>@theme { --color-<name>-50…950: … }primaryaccentbrand700900bg-<palette>-700hover:bg-<palette>-900focus:ring-<palette>-900focus:outline-none focus:ring-4 focus:ring-<color>cursor-not-allowed opacity-50rounded-lgrounded-fullroundedpx-5 py-2.5p-2.5p-4 md:p-6Texttext-2xl font-bold<h2><Text as="h2" variant="headline" bold>windowdocumentwindowdocumentleafletreact-leaflet// Next.js
const Map = dynamic(() => import("components/molecules/Map").then((m) => m.Map), {
ssr: false
});
// TanStack Start
const Map = lazy(() => import("components/molecules/Map").then((m) => ({ default: m.Map })));
// ...render under <ClientOnly fallback={<MapSkeleton />}>{<Map />}</ClientOnly>
// Plain React.lazy works too, just don't render the lazy component on the server pass.{ text: { en, es, ... } }next-intlreact-i18nextundefined// Shape only, wire to whichever i18n source the project actually uses.
const language = useCurrentLanguage();
const label = entity.text[language] ?? entity.text[DEFAULT_LANGUAGE];t("key")import { type PropsWithChildren, type ButtonHTMLAttributes, type Ref } from "react";
import { FontAwesomeIcon, type FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
import { Button, type ButtonProps } from "components/atoms";
import { ColorVariants, SizeVariants, type SizeVariant } from "types/variants";
import { classNames } from "utils";
// Reuses the base `Button` atom (defined in §5).
export interface IconButtonProps extends Omit<ButtonProps, "size" | "ref"> {
ref?: Ref<HTMLButtonElement>;
icon: FontAwesomeIconProps["icon"];
iconPosition?: "left" | "right";
size?: SizeVariant;
label: string; // sr-only fallback when icon-only
}
const IconGapDict: Record<SizeVariant, string> = {
tiny: "gap-1",
small: "gap-1.5",
base: "gap-2",
medium: "gap-2",
large: "gap-2.5"
};
export function IconButton({
ref,
icon,
iconPosition = "left",
size = SizeVariants.medium,
variant = ColorVariants.primary,
label,
children,
className,
...rest
}: PropsWithChildren<IconButtonProps>) {
return (
<Button
ref={ref}
size={size}
variant={variant}
className={classNames("inline-flex", "items-center", IconGapDict[size], className)}
{...rest}
>
{iconPosition === "left" ? <FontAwesomeIcon icon={icon} /> : null}
{children ? <span>{children}</span> : <span className="sr-only">{label}</span>}
{iconPosition === "right" ? <FontAwesomeIcon icon={icon} /> : null}
</Button>
);
}refOmit<ButtonProps, "size" | "ref">classNameforwardRefApplies to any React 19 setup, Vite SPA, Next.js (App or Pages Router), Remix, TanStack Start, etc. Subsections labeled "App Router / RSC only" are framework-specific; skip them on a plain Vite SPA. Everything else (compiler, refs, hooks,, accessibility, performance, TypeScript) is universal.use()
Applies only if the React Compiler is actually enabled in the build (wired into Babel/SWC, or the Next.jsbabel-plugin-react-compilerflag, etc.). On a vanilla React 19 project without the compiler, the rules below will produce real perf regressions, keep your existing memoization until the compiler is turned on.experimental.reactCompiler
useMemouseCallbackReact.memoreact-compiler-runtimeMath.random()Date.now()refforwardRefrefforwardRef// React 19 idiom, no forwardRef
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: Ref<HTMLButtonElement>;
variant?: ColorVariant;
size?: SizeVariant;
}
export function Button({
ref,
variant = "primary",
size = SizeVariants.medium,
className,
children,
...rest
}: PropsWithChildren<ButtonProps>) {
return (
<button ref={ref} className={classNames(/* ... */, className)} {...rest}>
{children}
</button>
);
}<div ref={(node) => {
if (!node) return;
const obs = new ResizeObserver(/* ... */);
obs.observe(node);
return () => obs.disconnect();
}} />useImperativeHandlerefuseEffectawaituseId()htmlForaria-describedbyaria-labelledbyMath.random()useSyncExternalStorematchMediauseEffectuseStateuseDeferredValueuseTransitionuseStatesetCount((c) => c + 1)use()use()import { use, Suspense } from "react";
function Title({ promise }: { promise: Promise<{ title: string }> }) {
const data = use(promise); // suspends until resolved
return <h1>{data.title}</h1>;
}
// Wrap with Suspense for loading UI:
<Suspense fallback={<SkeletonCard />}>
<Title promise={titlePromise} />
</Suspense>use()use(SomeContext)useContext/useActionState/useFormStatusare usable in any React 19 environment (Vite SPA included), they don't strictly require RSC. They shine brightest with Server Actions, but in a plain SPA you can pass anyuseOptimisticreducer and get the same pending/state ergonomics.(state, formData) => Promise<state>is fully framework-agnostic.useTransition
useActionState(state, formData) => newState<form>// "use client" is needed only in RSC frameworks (Next.js App Router, etc.).
// Plain SPA (Vite, etc.): drop the directive, the file is already a client module.
import { useActionState } from "react";
export function Subscribe({ action }: { action: (s: State, fd: FormData) => Promise<State> }) {
const [state, submit, isPending] = useActionState(action, { error: null });
return (
<form action={submit}>
<input name="email" required />
<Button type="submit" disabled={isPending}>Subscribe</Button>
{state.error ? <Alert variant="danger">{state.error}</Alert> : null}
</form>
);
}useFormStatus<form>import { useFormStatus } from "react-dom";
export function SubmitButton({ children }: PropsWithChildren) {
const { pending } = useFormStatus();
return <Button type="submit" disabled={pending}>{children}</Button>;
}useOptimisticconst [optimisticItems, addOptimistic] = useOptimistic(items, (state, next: Item) => [...state, next]);useTransitionstartTransition(async () => { await save(); /* navigate or refresh */ })If the project has both an App Router and a Pages Router (or equivalent split), apply these hooks where the React 19 runtime is wired up. For non-React-19 trees, drive submit buttons off the project's existing mutation state (e.g.).mutation.isPending
"use client""use server"await"use client""use client"children<ClientShell>{serverNode}</ClientShell>"use server"React.lazynext/dynamicuse()<Suspense fallback={<SkeletonCard />} />onUncaughtErroronCaughtErrorcreateRootuse()<title><meta><link><style><head>react-domimport { preconnect, preload, prefetchDNS } from "react-dom"aria-label<span className="sr-only">…</span><button>type="button" | "submit" | "reset""button"<dialog>aria-modalaria-labelEscapefocus:outline-none focus:ring-4 focus:ring-<color>:focus-visible:focusmotion-safe:prefers-reduced-motionaltalt=""next/imageunpic@unpic/react<img>loading="lazy"widthheightdecoding="async"windowdocumentnext/dynamicReact.lazy(() => import("./Heavy"))<Suspense>memouseDeferredValuetsconfigstrictnoUncheckedIndexedAccessT | undefined?? fallbackifReactNodeReactElementcloneElementPropsWithChildren<T>Readonly<PropsWithChildren<T>>MouseEventHandler<HTMLButtonElement>ChangeEventHandler<HTMLInputElement>(e: any) => voidPartial<T>refRef<T>RefObject<T> | RefCallback<T> | nullLegacyRefMutableRefObjectimport clsx from "clsx"classNamesclassNames"utils"classNames("flex items-center")"sm" | "md" | "lg"SizeVariantsColorVariantsExtract<>as: ElementTypecolor="red"classNameatoms/molecules/dark:__tests__fetch()className...SizeDict[size]...ColorDict[variant]classNames(...)forwardRefrefuseMemouseCallbackReact.memouseEffect"use client"useFormStatususeActionStateuseOptimistic"use server"defaultPropspropTypeswindowdocumentlocalStorageuseEffectuseSyncExternalStorematchMediakeyMath.random()useId():focus:focus-visible<button>type<div onClick={...}>role<button>LegacyRefMutableRefObjectRef<T><components>/<layer>/Foo.tsxexport function FooHTMLAttributes<...>className?variantsizeSizeDictColorDictclassNames(...)className...restPropsWithChildren<...>Readonly<...>index.ts__tests__/Foo.test.tsxasrefforwardRef