Imported from jtayped/iaeste-web (
packages/ui/AGENTS.md). Install upstream withnpx skills add jtayped/iaeste-web --skill ui. Copyright stays with the author.
@repo/ui
Shared component library for all three Next.js apps.
HeroUI v3 is the component base. Almost every component here is a thin
wrapper around a HeroUI subpath import, and the handful that are not are listed
under Retained with the reason. plans/checklist.md
records the migration and the decisions behind it — read its final notes before
changing a component's shape.
The wrapper rule
Only this package imports HeroUI or React Aria. Apps import from @repo/ui/*,
always. rg "@heroui|react-aria" apps/ must stay empty.
import {
Foo as HeroUIFoo,
type FooProps as HeroUIFooProps,
} from "@heroui/react/foo";
Import from the subpath, never the barrel. The alias keeps the local name free for our own export, which is what consumers see.
Adding or wrapping a component
- Check
packages/ui/node_modules/@heroui/react/dist/components/<name>/for the real.d.tsand compiled.js, and@heroui/styles/dist/components/<name>.cssfor the classes it applies. HeroUI's docs and NextUI v2's are both wrong about v3 often enough that the installed package is the only source worth trusting. - Write the wrapper in
src/components/ui/<name>.tsx. The"./*"export maps it to@repo/ui/<name>— no barrel to update. - Add anything new you import to
dependenciesinpackage.json, thennpm installfrom the repo root. max-lineswarns at 300 and warnings fail CI.sidebaris split acrosssidebar.tsx,sidebar-context.tsx,sidebar-group.tsxandsidebar-menu.tsxfor that reason, with the last three re-exported fromsidebar.tsxso consumers still import only@repo/ui/sidebar.
Things that have bitten this package more than once:
- Fold HeroUI's compound parts. Every overlay wrapper's
*Contentis HeroUI's backdrop + container + dialog (or popover + menu) in one component, andTabsListis its container + list. Nobody wants fewer than all of them, and a caller that has to nest four components correctly will eventually not. - Do not use HeroUI's
*.Triggerparts.AlertDialog.Trigger,Modal.Trigger,Popover.TriggerandTooltip.Triggerall render a<div role="button">. React Aria'sDialogTrigger/MenuTrigger/TooltipTriggeralready hand their props to a focusable child through context, so the sharedButtonis the trigger with no wrapper at all. For a child that is not a React Aria control — the sidebar's plain<button>— wrap it in React Aria'sPressable(press) orFocusable(focus only), which clone the child and merge props and refs onto it. That is what Radix'sasChildwas doing. - Never put
"use client"onbutton.tsx. It taints every export, including the purebuttonVariants()helper that server components inapps/inscripcionscall directly, and breaks static prerendering. - HeroUI's Button is a real
<button>with no polymorphic escape hatch. For a link that looks like a button, putbuttonVariants({...})on the app's ownLink— never wrap a link in a button. - Watch for names that collide with different meanings. HeroUI's
secondaryis a neutral grey where ours is IAESTE blue, and itsBadgeis a notification dot where ours is a status label. Both are mapped onto neutral HeroUI carriers and repainted by.button--brand/.chip--brandinglobals.css. Mapping either by name would have looked like it worked.
Theme tokens
All of them live in src/globals.css, in the @layer base block:
:root for light, .dark for the admin's dark mode. HeroUI derives
--accent-hover, --danger-soft-foreground and friends from the base tokens,
so overriding --accent retheme's every variant that uses it.
Below that, an @layer components block pins HeroUI's defaults back to the
IAESTE scale. HeroUI v3 styles through semantic classes, not utilities —
buttonVariants() returns button button--primary button--md, and the colour
lives in --button-bg / --button-fg custom properties the variant class sets.
So a new variant is another .button--* class in globals.css, not utility
soup in the wrapper. @heroui/styles declares
@layer theme, base, components, utilities, so anything a caller passes through
className still wins.
What is pinned there and why is written in the comments; the recurring themes are that HeroUI is much rounder than IAESTE (pill buttons, 32px overlays) and carries floating surfaces on a shadow alone, which leaves a white panel with no edge against a white page.
The @theme inline block at the top of the file maps legacy utility names
(bg-primary, text-muted-foreground, border-input, the sidebar and chart
colours) onto HeroUI's tokens, so app code written before the migration keeps
working.
Radius scale
One vocabulary, pinned in globals.css, for all three apps. App code that
hand-rolls a surface spells it the same way.
| Value | What it is |
|---|---|
rounded-2xl (16px) |
Cards, panels and floating surfaces: cards, alerts, popovers, menus, dialogs, toasts. |
rounded-xl (12px) |
Controls and fields: buttons, inputs, textareas, triggers, segmented-control tracks. |
rounded-lg (8px) |
Anything nested inside one of those: menu and list rows, calendar cells, the pill inside a tab track. |
rounded-full |
Icon-only buttons, and chips. |
Three steps and a circle. Nothing else — a rounded-md box beside a Card is
the drift this exists to stop, and there is no fifth value waiting to be
invented for a particular screen.
Fields never pin their own corner — --field-radius carries the 12px, and
HeroUI's rounded-field puts it on every field carrier it ships, so a date
picker or an OTP input added later inherits it. The card's edge and shadow come
from --card-border-color and --card-shadow-color, which are defined per
theme because --foreground is near-white under .dark and would paint a halo.
Media is not a surface: a photograph or a thumbnail takes whatever radius the
box it sits in needs, and the blog's images stay at rounded-xl.
React Hook Form
Form is React Hook Form's FormProvider; HeroUI's Form renders an HTML
<form>. They are not interchangeable, and this package deliberately does not
export the second one.
One pattern, used by both forms in the repo:
<FormField
control={form.control}
name="name"
render={({ field, fieldState }) => (
<TextField {...fieldProps(field, fieldState)}>
<FormLabel>nom</FormLabel>
<Input ref={field.ref} className={FIELD_CONTROL} />
<FormMessage className={FIELD_HINT} />
</TextField>
)}
/>
@repo/ui/text-fieldis the field root. OneuseTextField()call inside mints the ids and threads them through React Aria context to the nestedLabel,Input/Textarea, description andFormMessage, which is what replaced the oldFormItem/FormControl/useFormFieldmachinery.- It sets
validationBehavior="aria": the shared Zod schemas own validity, and React Aria must never raise a browser validation bubble of its own. fieldProps(field, fieldState)is the single React Hook Form → HeroUI mapping.field.refis deliberately excluded — it belongs on the control, because that is what React Hook Form focuses on a failed submit.FormMessageis HeroUI'sFieldError, gated on the root'sisInvalid, which is what makes it render once and only when invalid. A control with no such root (the year radio group, the OTP, the degree picker) renders its message by hand and says so in a comment.
Retained components
Not everything here is HeroUI, and each exception has a reason:
navigation-menu(Radix) — the public site's main navigation. Radix tracks which sibling a transition came from and slides the viewport accordingly (data-motion), and sizes it from--radix-navigation-menu-viewport-height/width. HeroUI has no equivalent.command(cmdk) andpopover(Radix) — the registration degree picker's ranked filter. HeroUI'sAutocompletefilters with a boolean predicate and keeps the collection in its declared order, which cannot expressscoreDegree's ranking (word-prefix matches above mid-word ones).popoveris retained only because that picker holds it up.table— plain<table>markup. React Aria's table is alwaysrole="grid"with arrow-key cell navigation, and Tab leaves the whole table rather than stepping through each row's link and buttons. For an admin list whose point is reaching a row's actions, that is a regression, and adding grid semantics to a static table is adding behaviour the app never asked for.button-group— HeroUI'sButtonGroupis a segmented control (gap-0, stripped inner radii). Ours is a spacing helper for buttons meant to stay distinct.sidebar*— the admin's collapsible sidebar: cookie-persisted state, aCmd/Ctrl+Bshortcut, an icon-collapsed mode. HeroUI has no sidebar.SidebarMenuButtonstays a plain<button>behind aSlotbecause it is a link as often as it is a button.logo,social,statistic,counter,typography,back-btn— product components, never shadcn output.
checkbox and switch are HeroUI-backed but currently rendered by nothing.
Rules
- Presentation only. No
process.env(ESLint enforces this), no data fetching, no app-specific copy. Anything that varies per app is a prop. - Consumers own the framework.
react,react-domandnextare peer dependencies. Anything else you import must be added todependencieshere. - Style through
src/globals.css. All three apps import it, so a token added here reaches all three. There is no JavaScript Tailwind config any more — Tailwind 4 reads the stylesheet, and so does Prettier's class sorter (tailwindStylesheetin.prettierrc). cn()from@repo/ui/lib/utilsmerges class names. Prettier sorts Tailwind classes insidecn(...)andcva(...).