Guidelines for building minimal, professional, and concise frontend components in the /web project using TypeScript, React Router v7, Tailwind CSS, and the CVA pattern.
This skill provides guidance for developing frontend components in the /web project for CRAN/E, a PWA that searches CRAN packages and authors.
@remixicon/react) for iconsThe design follows a clean, minimal aesthetic with:
All UI components follow these patterns:
PropsWithChildren when accepting children, combine with VariantProps<typeof twBase> from CVAcva() for reusable, type-safe class compositionComponent.displayName for debuggingHeader (app/modules/header.tsx):
PageContent (app/modules/page-content.tsx):
full-width class for spanning viewport widthPageContentSection (app/modules/page-content-section.tsx):
InfoPill (app/modules/info-pill.tsx):
InfoCard (app/modules/info-card.tsx):
Tag (app/modules/tag.tsx):
Separator (app/modules/separator.tsx):
<hr> with opacity-20 for subtle dividersProse (app/modules/prose.tsx):
Anchors & AnchorLink (app/modules/anchors.tsx):
Use Radix Colors with semantic variants:
Text colors:
text-gray-normal: Body texttext-gray-dim: Secondary/muted texttext-gray-12/text-gray-4: Prose (dark/light mode)Reference pages follow this structure:
Example from _page.privacy._index.tsx:
<Header gradient="sand" headline="Privacy" subline="..." />
<Anchors anchorIds={...}>...</Anchors>
<PageContent>
<PageContentSection headline="General" fragment="general">
<p>...</p>
</PageContentSection>
<Separator />
<PageContentSection headline="..." fragment="...">
...
</PageContentSection>
</PageContent>
_page.* files: Public pages with layout wrapper_page.package.$packageName.tsx: Package detail pages_page.author.$authorName.tsx: Author profile pages_page.privacy._index.tsx: Static content pagesapi.*: API endpoints (search, MCP)*.og.*: Open Graph image generationUse React Router's loader pattern:
export const loader: LoaderFunction = async ({ params }) => {
const data = await Service.fetchData(params.id);
if (!data) throw data(null, { status: 404 });
return data(data);
};
Access via useLoaderData<LoaderData>().
Use mergeMeta() helper for combining meta tags:
export const meta = mergeMeta(({ data }) => {
const title = `${data.name} | CRAN/E`;
return [
{ title },
{ name: "description", content: data.description },
{ property: "og:title", content: title },
{ property: "og:image", content: `${url}/og` },
];
});
dark: variantsmd: breakpoint for desktop adjustmentsImport from @remixicon/react:
import { RiArrowRightSLine, RiExternalLinkLine } from "@remixicon/react";
Size prop for consistency (typically 16 or 20).
import { cva, VariantProps } from "cva";
import { PropsWithChildren, ReactNode } from "react";
type Props = PropsWithChildren<
VariantProps<typeof twBase> & {
// Custom props
}
>;
const twBase = cva({
base: "...",
variants: {
size: { ... },
},
defaultVariants: { ... },
});
export function Component(props: Props) {
const { children, ...rest } = props;
return <div className={twBase(rest)}>{children}</div>;
}
Component.displayName = "Component";
npm run lint from /webnpm run typecheck from /webnpm run build from /web/web/app/routes/*.tsx/web/app/modules/*.tsx/web/app/data/*.ts/web/app/data/*.shape.ts (Zod schemas), *.types.generated.ts (Supabase).nvmrc)legacy-peer-deps = true in .npmrc).env.example