Implements frontend interfaces using React 19, Inertia.js v2, Tailwind CSS v4, Radix UI, and Wayfinder. Activates when creating or modifying React pages, components, layouts, or forms; styling with Tailwind; using Radix UI primitives; referencing backend routes via Wayfinder; implementing deferred props, prefetching, or polling; or when the user mentions frontend, UI, interface, components, pages, forms, navigation, styling, or visual changes.
Unified skill for implementing frontend interfaces with the project's full stack: React 19 + TypeScript, Inertia.js v2, Tailwind CSS v4, Radix UI, and Wayfinder.
docs/design/ for UI design specifications (if they exist)resources/js/components/ui/ before creating new onessearch-docs for version-specific documentation on any frameworkresources/js/
├── components/ # Shared components
│ ├── ui/ # UI primitives (Button, Card, Dialog, etc.)
│ └── [domain].tsx # Domain components (app-sidebar, nav-main, etc.)
├── hooks/ # Custom React hooks
├── layouts/ # Page layouts
│ ├── app/ # App layouts (sidebar, header)
│ ├── auth/ # Auth layouts (simple, split, card)
│ └── settings/ # Settings layout
├── pages/ # Inertia page components
│ ├── auth/ # Auth pages
│ ├── settings/ # Settings pages
│ └── [domain]/ # Domain pages
├── lib/ # Utilities (cn, toUrl)
├── types/ # TypeScript type definitions
└── actions/ # Wayfinder generated (do not edit)
Always use the cn() utility for conditional/merged classes:
import { cn } from '@/lib/utils'
<div className={cn('base-classes', conditional && 'active-class', className)} />
Use class-variance-authority for components with visual variants:
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
destructive: 'bg-destructive text-white',
outline: 'border border-input text-foreground',
},
},
defaultVariants: { variant: 'default' },
}
)
function Badge({ className, variant, ...props }: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
The project uses Radix UI for accessible headless components. Existing primitives in components/ui/:
| Primitive | File | Radix Package |
|---|---|---|
| Dialog/Modal | ui/dialog.tsx | @radix-ui/react-dialog |
| Dropdown Menu | ui/dropdown-menu.tsx | @radix-ui/react-dropdown-menu |
| Select | ui/select.tsx | @radix-ui/react-select |
| Tooltip | ui/tooltip.tsx | @radix-ui/react-tooltip |
| Avatar | ui/avatar.tsx | @radix-ui/react-avatar |
| Checkbox | ui/checkbox.tsx | @radix-ui/react-checkbox |
| Collapsible | ui/collapsible.tsx | @radix-ui/react-collapsible |
| Toggle | ui/toggle.tsx | @radix-ui/react-toggle |
| Sidebar | ui/sidebar.tsx | Custom (uses Sheet internally) |
| Sheet | ui/sheet.tsx | @radix-ui/react-dialog |
When needing a new primitive: check if Radix UI has it, create wrapper in components/ui/, follow existing patterns (data-slot, cn utility).
For detailed component patterns and Radix integration, see references/component-patterns.md.
Pages live in resources/js/pages/. Receive props from Laravel controllers via Inertia::render().
export default function UsersIndex({ users }: { users: App.Models.User[] }) {
return (
<div>
<h1>Users</h1>
{users.map(user => <div key={user.id}>{user.name}</div>)}
</div>
)
}
Use <Link> for client-side navigation (never <a>):
import { Link } from '@inertiajs/react'
<Link href="/users">Users</Link>
<Link href="/logout" method="post" as="button">Logout</Link>
<Link href="/users" prefetch>Users (prefetched)</Link>
import { Form } from '@inertiajs/react'
<Form action="/users" method="post" resetOnSuccess>
{({ errors, processing, wasSuccessful }) => (
<>
<input type="text" name="name" />
{errors.name && <div className="text-destructive text-sm">{errors.name}</div>}
<button type="submit" disabled={processing}>
{processing ? 'Saving...' : 'Save'}
</button>
{wasSuccessful && <div>Saved!</div>}
</>
)}
</Form>
Render props: errors, hasErrors, processing, progress, wasSuccessful, recentlySuccessful, clearErrors, resetAndClearErrors, defaults, isDirty, reset, submit.
For programmatic control:
import { useForm } from '@inertiajs/react'
const { data, setData, post, processing, errors, reset } = useForm({
name: '', email: '',
})
function submit(e: React.FormEvent) {
e.preventDefault()
post('/users', { onSuccess: () => reset() })
}
Load data after initial page render with skeleton fallback:
export default function Dashboard({ stats }: { stats?: DashboardStats }) {
if (!stats) return <Skeleton className="h-32 w-full" />
return <StatsGrid stats={stats} />
}
import { router } from '@inertiajs/react'
import { useEffect } from 'react'
useEffect(() => {
const interval = setInterval(() => router.reload({ only: ['stats'] }), 5000)
return () => clearInterval(interval)
}, [])
import { WhenVisible } from '@inertiajs/react'
<WhenVisible data="users" params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>} />
Persistent layouts avoid re-mounting on navigation:
import AppLayout from '@/layouts/app-layout'
function Dashboard({ stats }) {
return <div>...</div>
}
Dashboard.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>
export default Dashboard
For detailed Inertia patterns, use search-docs with queries like shared data, persistent layouts, partial reloads, merge props.
Import TypeScript route functions generated from Laravel routes:
// Controller action imports (preferred for tree-shaking)
import { show, store } from '@/actions/App/Http/Controllers/PostController'
// Named route imports
import { show as postShow } from '@/routes/post'
// Methods
show.url(1) // "/posts/1"
show(1) // { url: "/posts/1", method: "get" }
store.form() // { action: "/posts", method: "post" }
// With Inertia Form
<Form {...store.form()}>...</Form>
// Query params
show(1, { query: { page: 2 } })
Regenerate after route changes: php artisan wayfinder:generate --no-interaction
@import "tailwindcss" (not @tailwind directives)@theme directive (not tailwind.config.js)bg-black/50 (not bg-opacity-50)shrink-* / grow-* (not flex-shrink-* / flex-grow-*)<!-- Flex with gap -->
<div class="flex items-center justify-between gap-4">...</div>
<!-- Responsive grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">...</div>
<!-- Stack -->
<div class="flex flex-col gap-4">...</div>
Use dark: variant, match existing patterns:
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">...</div>
Breakpoints: sm (640), md (768), lg (1024), xl (1280), 2xl (1536). Mobile-first.
For detailed Tailwind patterns, use search-docs with relevant queries.
resources/js/types/App.Models.* namespace for model typesReact.ComponentProps<'element'> for extending HTML element propsBefore considering frontend work complete:
ui/ components (no reinventing)cn() for conditional classesnpm run lint && npm run types passes<a> instead of <Link> (breaks SPA navigation)<form> without e.preventDefault() (use <Form> component instead)undefined initially)ui/ primitivesbg-opacity-*, flex-shrink-*, @tailwind)key prop in .map() iterationsprocessing state in form submissions