UI Components and Styling
The Eventuall frontend uses Tailwind CSS for styling and shadcn/ui for the component library. Components follow a consistent pattern: base UI primitives live in src/components/ui/, while feature-specific components live alongside their features.
How shadcn/ui Works
shadcn/ui is different from traditional component libraries like Material UI or Ant Design. Instead of installing a package, you copy component source code into your project. The components are built on:
- Radix UI — unstyled, accessible primitives (dialog, dropdown, tooltip, etc.)
- Tailwind CSS — utility-first styling
- class-variance-authority (cva) — variant management (like "default", "destructive", "outline" for buttons)
- tailwind-merge — intelligently merges Tailwind class names without conflicts
The advantage: you own the code. You can modify any component without fighting an upstream library's API. The trade-off: you're responsible for maintaining these components yourself.
The Button System
The Button component (apps/webapp/src/components/ui/button.tsx) demonstrates the most complex pattern in the UI layer. It has two variant systems:
Standard shadcn/ui variants for general UI:
default— primary brand colordestructive— red, for dangerous actionsoutline— bordered, transparent backgroundsecondary— muted backgroundghost— invisible until hoveredlink— styled as a text link
Custom Eventuall variants for brand-specific interactions:
blue— Eventuall brand bluered— Eventuall red for warningsgreen— success actionsslideup— grey background, left-aligned, for slide-up panelswhite— high contrast for dark backgroundsicon— transparent, for icon-only buttons
An isEventuallVariant() function auto-detects which system to use based on the variant name. You don't need to think about it — just pass the variant name and the right styles apply:
<Button variant="default">Standard Primary</Button>
<Button variant="blue">Eventuall Primary</Button>
<Button variant="destructive">Delete</Button>
<Button variant="icon" size="icon"><TrashIcon /></Button>
Component Organization
apps/webapp/src/components/
├── ui/ # Base shadcn/ui components
│ ├── button.tsx
│ ├── card.tsx
│ ├── dialog.tsx
│ ├── form.tsx # React Hook Form integration
│ ├── input.tsx
│ ├── select.tsx
│ ├── table.tsx
│ └── ...
├── livekit/ # LiveKit video components
│ ├── EventRoomWebRTC.tsx
│ ├── stage/
│ └── vetting/
├── chat/ # Twilio chat components
│ └── TwilioClientContextProvider.tsx
├── forms/ # Form components with validation
└── layouts/ # Layout components
Convention: Components in ui/ are generic and reusable across any feature. Components in feature directories (like livekit/ or chat/) are specific to that feature and may import from ui/.
Building Forms
Forms in Eventuall use React Hook Form with Zod validation and shadcn/ui form components. The pattern is:
- Define a Zod schema for the form data
- Create a
useFormhook withzodResolver - Use
FormFieldcomponents to wire up each input - Submit via Server Action or tRPC mutation
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const schema = z.object({
name: z.string().min(3, "Name must be at least 3 characters"),
date: z.string().refine(d => new Date(d) > new Date(), "Must be in the future"),
});
type FormData = z.infer<typeof schema>;
export function EventForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: "", date: "" },
});
const onSubmit = async (data: FormData) => {
// Call Server Action or tRPC mutation
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Event Name</FormLabel>
<FormControl>
<Input placeholder="Summer Concert" {...field} />
</FormControl>
<FormMessage /> {/* Shows Zod validation errors */}
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating..." : "Create Event"}
</Button>
</form>
</Form>
);
}
The FormMessage component automatically displays the Zod error message for its field. No manual error wiring needed.
Pitfall: The
Formcomponent from shadcn/ui is a wrapper around React Hook Form'sFormProvider. Don't confuse it with the native HTML<form>element. The shadcnFormprovides context thatFormField,FormItem, andFormMessageconsume. If you skip the wrapper, field errors won't display.
Tailwind CSS Patterns
Responsive Design
All layouts are mobile-first. Use Tailwind's breakpoint prefixes to add styles at larger screens:
<div className="px-4 md:px-8 lg:px-12"> {/* Padding increases with screen */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> {/* Grid columns */}
<div className="hidden lg:block"> {/* Only show on large screens */}
Dark Mode
Tailwind's dark: prefix is used throughout. The theme is controlled by a ThemeProvider that respects the user's system preference:
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
The `cn()` Utility
The cn() function (from apps/webapp/src/lib/utils.ts) merges Tailwind classes intelligently:
import { cn } from "@/lib/utils";
// Merge base classes with conditional classes
<div className={cn(
"rounded-lg border p-4", // Always applied
isActive && "border-blue-500", // Conditional
className // Passed from parent
)}>
This prevents class conflicts (like p-4 and p-6 both being applied) by using tailwind-merge under the hood.
Zod Validation
Zod is used everywhere for runtime validation: tRPC inputs, Server Action schemas, form validation, and API response parsing. The pattern is always the same — define a schema, infer the TypeScript type, use the schema for validation:
// Define
const eventSchema = z.object({
name: z.string().min(3).max(100),
status: z.enum(["draft", "active", "archive"]),
startsAt: z.date(),
maxAttendees: z.number().positive().optional(),
});
// Infer type
type Event = z.infer<typeof eventSchema>;
// Validate
const result = eventSchema.safeParse(input);
if (!result.success) {
console.error(result.error.flatten());
}
Common Patterns
Schema composition — build complex schemas from simple ones:
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}$/),
});
const customerSchema = z.object({
name: z.string(),
billing: addressSchema,
shipping: addressSchema.optional(),
});
Transform — convert data during validation:
const emailSchema = z.string().email().toLowerCase().trim();
// " JOHN@Example.COM " → "john@example.com"
Refine — custom validation logic:
const dateRangeSchema = z.object({
start: z.date(),
end: z.date(),
}).refine(
(data) => data.end > data.start,
{ message: "End date must be after start date", path: ["end"] }
);
Architect's Note: Zod schemas should be defined once and reused across the stack. A schema defined for a tRPC input can also be used as the React Hook Form resolver. If you find yourself duplicating validation logic, extract the schema into a shared file in
src/lib/validations/.
Adding a New Component
When you need a new shadcn/ui component:
- Check the shadcn/ui docs for the component
- Copy the component code into
apps/webapp/src/components/ui/ - Install any missing Radix UI dependencies
- Customize the styling to match the Eventuall design system
When building a custom feature component:
- Create a new file in the appropriate feature directory
- Use
"use client"only if the component needs interactivity - Import base components from
@/components/ui/ - Define an
interfacefor props (the codebase prefers interfaces over types for object shapes) - Use
cn()for class merging andReact.forwardRefif the component wraps a native element