Component Patterns
Nexgent uses shadcn/ui as the component library foundation, built on Radix UI primitives with Tailwind CSS styling.
Component Library
shadcn/ui
shadcn/ui provides unstyled, accessible components that are copied into the project:
shared/components/ui/
├── button.tsx
├── card.tsx
├── dialog.tsx
├── dropdown-menu.tsx
├── form.tsx
├── input.tsx
├── select.tsx
├── table.tsx
├── tabs.tsx
├── toast.tsx
└── ...Unlike npm packages, shadcn/ui components are copied into your project. This gives full control over styling and behavior while maintaining accessibility.
Installation
npx shadcn@latest add button card dialogComponents are installed to shared/components/ui/ and can be customized directly.
Component Categories
UI Components (shared/components/ui/)
Base components from shadcn/ui:
import { Button } from '@/shared/components/ui/button';
import { Card, CardHeader, CardContent } from '@/shared/components/ui/card';
import { Dialog, DialogTrigger, DialogContent } from '@/shared/components/ui/dialog';Layout Components (shared/components/layout/)
Application layout components:
import { AppSidebar } from '@/shared/components/layout/app-sidebar';
import { NavMain } from '@/shared/components/layout/nav-main';
import { NavUser } from '@/shared/components/layout/nav-user';Feature Components (features/*/components/)
Domain-specific components:
import { LivePositionsTable } from '@/features/positions';
import { AgentSwitcher } from '@/features/agents';
import { TradingSignalsTable } from '@/features/trading-signals';Component Structure
Basic Component
// features/positions/components/live-positions-table/live-positions-table.tsx
'use client';
import { cn } from '@/shared/utils/cn';
import { Table, TableHeader, TableBody, TableRow, TableCell } from '@/shared/components/ui/table';
interface LivePositionsTableProps {
className?: string;
}
export function LivePositionsTable({ className }: LivePositionsTableProps) {
const { selectedAgentId } = useAgentSelection();
const { positions, isConnected } = useWebSocket(selectedAgentId);
return (
<Card className={cn('', className)}>
<CardHeader>
<CardTitle>Live Positions</CardTitle>
</CardHeader>
<CardContent>
<Table>
{/* Table content */}
</Table>
</CardContent>
</Card>
);
}Dialog Component
// features/positions/components/close-position-dialog/close-position-dialog.tsx
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/components/ui/dialog';
import { Button } from '@/shared/components/ui/button';
interface ClosePositionDialogProps {
position: LivePosition;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ClosePositionDialog({ position, open, onOpenChange }: ClosePositionDialogProps) {
const closePosition = useClosePosition();
const handleClose = async () => {
await closePosition.mutateAsync({
agentId: position.agentId,
positionId: position.id,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Close Position</DialogTitle>
<DialogDescription>
Are you sure you want to close your {position.tokenSymbol} position?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p>Current P/L: {formatCurrency(position.profitLossUsd)}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleClose}
disabled={closePosition.isPending}
>
{closePosition.isPending ? 'Closing...' : 'Close Position'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Form Components
Forms use React Hook Form with shadcn/ui form components:
// features/agents/components/create-agent-dialog/create-agent-dialog.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateAgentSchema } from 'nexgent-open-source-trading-engine/shared';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/shared/components/ui/form';
import { Input } from '@/shared/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps) {
const form = useForm({
resolver: zodResolver(CreateAgentSchema),
defaultValues: {
name: '',
tradingMode: 'simulation',
},
});
const createAgent = useCreateAgent();
const onSubmit = async (data: CreateAgentInput) => {
await createAgent.mutateAsync(data);
onOpenChange(false);
form.reset();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Trading Agent" {...field} />
</FormControl>
<FormDescription>
A name to identify this agent.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tradingMode"
render={({ field }) => (
<FormItem>
<FormLabel>Trading Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="simulation">Simulation</SelectItem>
<SelectItem value="live">Live</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={createAgent.isPending}>
{createAgent.isPending ? 'Creating...' : 'Create Agent'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}Loading States
Loading Components
// shared/components/loading/loading-spinner.tsx
export function LoadingSpinner({ className }: { className?: string }) {
return (
<div className={cn('animate-spin rounded-full border-2 border-muted border-t-primary', className)} />
);
}
// shared/components/loading/card-skeleton.tsx
export function CardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
);
}
// shared/components/loading/table-skeleton.tsx
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<Table>
<TableHeader>
<TableRow>
{Array(4).fill(0).map((_, i) => (
<TableHead key={i}><Skeleton className="h-4 w-20" /></TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array(rows).fill(0).map((_, i) => (
<TableRow key={i}>
{Array(4).fill(0).map((_, j) => (
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}Usage Pattern
function PositionsSection() {
const { positions, isLoading } = usePositions();
if (isLoading) {
return <TableSkeleton rows={3} />;
}
if (positions.length === 0) {
return <EmptyState message="No positions" />;
}
return <PositionsTable positions={positions} />;
}Error Handling
Error Boundary
// shared/components/error/error-boundary.tsx
'use client';
import { Component, type ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback || <ErrorState error={this.state.error} />;
}
return this.props.children;
}
}Error State Component
// shared/components/error/error-state.tsx
export function ErrorState({ error, retry }: { error?: Error; retry?: () => void }) {
return (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-8">
<AlertCircle className="h-12 w-12 text-destructive" />
<p className="text-sm text-muted-foreground">
{error?.message || 'Something went wrong'}
</p>
{retry && (
<Button variant="outline" onClick={retry}>
Try Again
</Button>
)}
</CardContent>
</Card>
);
}Toast Notifications
// Using the toast hook
import { useToast } from '@/shared/hooks/use-toast';
function SaveButton() {
const { toast } = useToast();
const updateConfig = useUpdateConfig();
const handleSave = async () => {
try {
await updateConfig.mutateAsync(data);
toast({
title: 'Settings saved',
description: 'Your trading configuration has been updated.',
});
} catch (error) {
toast({
title: 'Error',
description: error.message || 'Failed to save settings',
variant: 'destructive',
});
}
};
return <Button onClick={handleSave}>Save</Button>;
}Styling Patterns
Tailwind CSS
All styling uses Tailwind CSS utility classes:
<div className="flex flex-col gap-4 p-6">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">Manage your trading agents</p>
</div>Class Merging
The cn utility merges Tailwind classes safely:
import { cn } from '@/shared/utils/cn';
function Card({ className, ...props }: CardProps) {
return (
<div className={cn('rounded-lg border bg-card p-6', className)} {...props} />
);
}Dark Mode
The app uses dark mode by default, configured in the root layout:
<html lang="en" className="dark">CSS variables handle theming:
/* globals.css */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--primary: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
/* ... */
}