Components
How the site builds UI with shadcn/ui, the four component buckets, the Radix + data-slot pattern, and cva variants.
Every pixel of UI on the Naalya site comes from one place: app/components/. There is no scattered styling, no one-off <div> soup. Instead the project leans on shadcn/ui — components you own and copy into your repo rather than install from npm — sitting on top of Radix UI primitives and Tailwind CSS v4, with class-variance-authority (cva) doing the variant math.
The single most useful thing to understand up front is that the components folder is sorted into buckets by intent, and almost every mistake people make here comes from putting a file in the wrong one. So before any code, get this mental model straight:
The one rule: compose, don't fork
The generated primitives in ui/ are vendored code — treat them like node_modules you can read. You rarely hand-edit them. When you need something bespoke, you compose the primitives into a new file in custom-ui/ or a feature bucket. Fork a primitive and the next shadcn add <same> silently overwrites your edits.
The four buckets
Choosing the right bucket is the most important structural decision you make when adding UI. There are four, and each answers a different question.
ui/
Generated shadcn primitives — button, dialog, input, form. Vendored: restyle globally or regenerate, never fork one file.
custom-ui/
Bespoke widgets and visual effects that COMPOSE ui/ primitives. Your code lives here.
ai-elements/
A vendored AI Elements / streamdown chat kit powering Naalya-AI. Not managed by the CLI.
sections/ · navigation/ · providers/
Feature buckets: page orchestrators, site chrome, and the provider tree.
ui/ is the base layer — 52 files, mostly generated shadcn primitives (button, card, dialog, form, select, sidebar, and ~45 more). Two project-authored files live here on purpose: optimized-image.tsx (use OptimizedImage instead of a raw img for CMS pictures — you get srcset, LQIP blur, and auto format) and spinner.tsx.
custom-ui/ is where your reusable UI goes. It has two flavours: composites that wrap primitives (phone.input.tsx, success-dialog.tsx, image-preview-dialog.tsx) and pure visual effects (aurora-text.tsx, meteors.tsx, particles.tsx). The composition is literal — image-preview-dialog.tsx just pulls the parts it needs out of ui/:
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";ai-elements/ is a vendored copy of the Vercel AI Elements / streamdown chat kit (conversation, message, response, prompt-input, and more) that powers the Naalya-AI chat. Several files carry biome-ignore-all banners marking them as vendored. The shadcn CLI does not manage these — don't try to regenerate them.
The feature buckets assemble everything else: sections/ holds page-level orchestrators (lugazi-admission.tsx), landing-page-components/ holds the smaller presentational cards they pull from, navigation/ holds navbar.tsx and footer.tsx, and providers/ holds the provider tree.
The decision rule
Need a raw primitive? Add it to ui/ with the CLI. Building a reusable widget or a visual effect on top of primitives? It goes in custom-ui/. Assembling a whole page region from widgets and CMS data? That is a sections/ orchestrator pulling from landing-page-components/.
Radix underneath, and the data-slot trick
Every interactive primitive is a thin wrapper around a @radix-ui/react-* package. Radix supplies the behavior — focus management, portals, keyboard handling — and the shadcn wrapper supplies the styling. The new-york style this project uses adds one extra convention you'll see everywhere: every wrapped part is tagged with a data-slot="..." attribute.
Here is the shape of ui/dialog.tsx — notice each piece declares its slot and merges className through cn():
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogContent({ className, children, ...props }) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
className={cn("fixed top-[50%] left-[50%] z-50 grid /* ... */", className)}
data-slot="dialog-content"
{...props}
/>
</DialogPortal>
);
}Why does the slot matter? Because it lets you restyle the inside of a primitive from the outside, without touching the primitive file. Tailwind's arbitrary-variant syntax can target a descendant by its slot. The chat input does exactly this to round a nested input group it doesn't own:
<PromptInput className="**:data-[slot=input-group]:rounded-3xl!" />Reach for the slot before you reach for a fork
Whenever you're tempted to edit a ui/ file just to tweak a nested element, ask whether **:data-[slot=name]:... can do it from the parent instead. It almost always can — and it keeps ui/ clean and regenerable.
cn() — the universal class merger
You'll see cn() in literally every component, so understand it once. It is clsx (which joins class names conditionally) piped through tailwind-merge (which de-duplicates conflicting Tailwind utilities, last-one-wins).
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}The payoff is that a caller can always override a base style. Because the incoming className is merged last, bg-secondary from a caller correctly beats a base bg-primary instead of producing two fighting classes:
// Caller wins: twMerge drops the base bg-primary in favour of the passed class.
<div className={cn("rounded-md bg-primary p-4", className)} />Always merge, never concatenate
Never build Tailwind strings with template literals or +. Use cn() so conflicting utilities resolve predictably. This is also why every primitive accepts a className prop and appends it last — it's the contract that makes overriding work.
Variants with cva
Any component with visual variants — a button with outline vs ghost, a badge with severities — defines them with class-variance-authority. The recipe never changes: a base string, a variants map, defaultVariants, props typed with VariantProps<typeof xVariants>, and both the component and its *Variants function exported.
ui/button.tsx is the canonical example. Note two project customizations baked in: the outline variant is branded (border-primary ... text-primary hover:bg-primary/4, not stock shadcn gray), and the project added icon-sm / icon-lg sizes.
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center /* base */", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-primary bg-background text-primary hover:bg-primary/4",
ghost: "hover:bg-accent hover:text-accent-foreground",
/* destructive, secondary, link ... */
},
size: { default: "h-9 px-4 py-2", sm: "h-8 px-3", "icon-sm": "size-8" /* ... */ },
},
defaultVariants: { variant: "default", size: "default" },
});
function Button({ className, variant, size, asChild = false, ...props }:
React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} data-slot="button" {...props} />;
}
export { Button, buttonVariants };The same pattern shows up in ui/badge.tsx, in ui/navigation-menu.tsx (navigationMenuTriggerStyle), and even outside ui/ — ai-elements/message.tsx keys a cva variant off group classes to style user vs assistant bubbles. Once you've read one, you've read them all.
asChild and the Radix Slot
That asChild prop on Button is worth a closer look, because it's how the project gets a button-styled link without duplicating styles. When asChild is true, the component renders Radix's Slot, which merges its props (including the cva className) onto its single child instead of rendering its own element.
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
// The button's cva classes flow onto the <Link>, which renders the real <a>.
<Button asChild size="lg">
<Link to={pageLink}>Explore campus</Link>
</Button>;Adding a component
When you need a primitive the project doesn't have yet, you don't write it — you let the CLI generate it. Run it from the frontend package root (the folder containing components.json). The CLI reads that config, drops the file into app/components/ui/, and installs any new dependency (a Radix package, sometimes lucide-react).
# Add one primitive
pnpm dlx shadcn@latest add tooltip
# Add several at once
pnpm dlx shadcn@latest add dialog popover command
# List/search what's available (run with no name)
pnpm dlx shadcn@latest addOnce it lands, import it through the @/components/ui alias (@/* resolves to app/*):
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";One subtlety: this is Tailwind v4, so there is no tailwind.config.js. If a new primitive needs keyframes — the accordion's accordion-up / accordion-down, for instance — they go into app/app.css, not a JS config. Styling & Theming covers where theme tokens and keyframes live.
A few primitives carry project-local exports — re-apply them
ui/select.tsx exports two extras beyond stock shadcn — SelectTriggerWithIcon and SelectContentWithIcon, used by the admission form's class dropdown. Re-running pnpm dlx shadcn@latest add select overwrites the file and drops them. After regenerating any primitive you've extended, diff it and re-apply your additions.
The primitive authoring checklist
If you do author or restyle a primitive, follow the house style so it stays consistent with the generated ones:
- Accept
classNameand append it last viacn(). - Spread
{...props}through to the underlying element. - Type the signature as
React.ComponentProps<'tag'>orReact.ComponentProps<typeof RadixPrimitive.Part>. - Tag each wrapper part with
data-slot="name". - If it has variants, use
cvaand export both the component and its*Variantsfunction.
What lives on the other pages
Two big topics deliberately don't live here, to keep this page about the component system rather than every feature built on it.
Forms. The react-hook-form + Zod + shadcn Form pattern is the most repeated pattern in the codebase, and it has its own home. See Forms for the gold-standard admission-form.tsx, binding non-native inputs, and the Zod v4 idioms.
Icons and toasts. The project uses @tabler/icons-react in feature code and lucide-react inside primitives — match whatever the surrounding file imports, and don't introduce iconsax-react (installed but unused). The sonner Toaster is mounted once in app.provider.tsx; call toast.* from anywhere, and never mount a second one. Both fit better alongside the styling discussion.
Where to go next
Forms
The react-hook-form + Zod pattern, the Form wrapper, and custom inputs that forwardRef.
Styling & Theming
Tailwind v4 CSS-first config, OKLCH tokens, the @theme mapping, and the theme provider.
Project Structure
Where everything lives: the app/ layout, query modules, aliases, and naming rules.
Adding a Page
The end-to-end recipe for shipping a page, including the components you assemble.