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 color
  • destructive — red, for dangerous actions
  • outline — bordered, transparent background
  • secondary — muted background
  • ghost — invisible until hovered
  • link — styled as a text link

Custom Eventuall variants for brand-specific interactions:

  • blue — Eventuall brand blue
  • red — Eventuall red for warnings
  • green — success actions
  • slideup — grey background, left-aligned, for slide-up panels
  • white — high contrast for dark backgrounds
  • icon — 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:

  1. Define a Zod schema for the form data
  2. Create a useForm hook with zodResolver
  3. Use FormField components to wire up each input
  4. 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 Form component from shadcn/ui is a wrapper around React Hook Form's FormProvider. Don't confuse it with the native HTML <form> element. The shadcn Form provides context that FormField, FormItem, and FormMessage consume. 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:

  1. Check the shadcn/ui docs for the component
  2. Copy the component code into apps/webapp/src/components/ui/
  3. Install any missing Radix UI dependencies
  4. Customize the styling to match the Eventuall design system

When building a custom feature component:

  1. Create a new file in the appropriate feature directory
  2. Use "use client" only if the component needs interactivity
  3. Import base components from @/components/ui/
  4. Define an interface for props (the codebase prefers interfaces over types for object shapes)
  5. Use cn() for class merging and React.forwardRef if the component wraps a native element