Components

Guide to BrewHoard's reusable component library built with shadcn-svelte and custom components.

BrewHoard uses a combination of shadcn-svelte components and custom components. This guide covers both.

Component Architecture

Text
src/
├── lib/components/         # Shared library components
│   ├── ui/                 # shadcn-svelte base components
│   │   ├── button/
│   │   ├── card/
│   │   ├── input/
│   │   └── ...
│   └── docs/               # Documentation components

└── components/             # Feature-specific components
    ├── auth/               # Login, registration
    ├── beer/               # Beer display
    ├── collection/         # Collection management
    ├── marketplace/        # Trading UI
    └── ratings/            # Review forms

shadcn-svelte Components

BrewHoard uses shadcn-svelte for accessible, customizable base components.

Installation

Components are already installed. To add new ones:

Bash
npx shadcn-svelte@latest add dialog
npx shadcn-svelte@latest add dropdown-menu

Button

Svelte
<script>
  import { Button } from '$lib/components/ui/button';
</script>

<!-- Variants -->
<Button variant="default">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>

<!-- Sizes -->
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon"><Icon /></Button>

<!-- States -->
<Button disabled>Disabled</Button>
<Button class="w-full">Full Width</Button>

Card

Svelte
<script>
  import * as Card from '$lib/components/ui/card';
</script>

<Card.Root>
  <Card.Header>
    <Card.Title>Beer Name</Card.Title>
    <Card.Description>Brewery Name</Card.Description>
  </Card.Header>
  <Card.Content>
    <p>ABV: 6.5%</p>
    <p>Style: IPA</p>
  </Card.Content>
  <Card.Footer>
    <Button>Add to Collection</Button>
  </Card.Footer>
</Card.Root>

Input

Svelte
<script>
  import { Input } from '$lib/components/ui/input';
  import { Label } from '$lib/components/ui/label';
  
  let email = $state('');
</script>

<div class="grid gap-2">
  <Label for="email">Email</Label>
  <Input 
    id="email" 
    type="email" 
    placeholder="user@example.com"
    bind:value={email}
  />
</div>

Dialog

Svelte
<script>
  import * as Dialog from '$lib/components/ui/dialog';
  import { Button } from '$lib/components/ui/button';
</script>

<Dialog.Root>
  <Dialog.Trigger asChild let:builder>
    <Button builders={[builder]}>Open Dialog</Button>
  </Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Confirm Action</Dialog.Title>
      <Dialog.Description>
        Are you sure you want to proceed?
      </Dialog.Description>
    </Dialog.Header>
    <Dialog.Footer>
      <Button variant="outline">Cancel</Button>
      <Button>Confirm</Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>

Select

Svelte
<script>
  import * as Select from '$lib/components/ui/select';
  
  let selectedStyle = $state('');
</script>

<Select.Root bind:value={selectedStyle}>
  <Select.Trigger class="w-[180px]">
    <Select.Value placeholder="Select style" />
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="ipa">IPA</Select.Item>
    <Select.Item value="stout">Stout</Select.Item>
    <Select.Item value="lager">Lager</Select.Item>
    <Select.Item value="pilsner">Pilsner</Select.Item>
  </Select.Content>
</Select.Root>

Custom Components

BeerCard

Displays a beer with basic information:

Svelte
<!-- src/components/beer/BeerCard.svelte -->
<script>
  import * as Card from '$lib/components/ui/card';
  import { Button } from '$lib/components/ui/button';
  
  /**
   * @typedef {Object} Props
   * @property {import('$lib/types').Beer} beer
   * @property {boolean} [showActions=true]
   * @property {(beer: import('$lib/types').Beer) => void} [onAdd]
   */
  
  /** @type {Props} */
  let { beer, showActions = true, onAdd } = $props();
</script>

<Card.Root class="overflow-hidden">
  <div class="aspect-square relative">
    {#if beer.imageUrl}
      <img 
        src={beer.imageUrl} 
        alt={beer.name}
        class="object-cover w-full h-full"
      />
    {:else}
      <div class="w-full h-full bg-amber-100 flex items-center justify-center">
        <span class="text-amber-600 text-4xl">🍺</span>
      </div>
    {/if}
    
    {#if beer.avgRating}
      <div class="absolute top-2 right-2 bg-white/90 px-2 py-1 rounded-full text-sm">
        ⭐ {beer.avgRating.toFixed(1)}
      </div>
    {/if}
  </div>
  
  <Card.Header class="pb-2">
    <Card.Title class="line-clamp-1">{beer.name}</Card.Title>
    <Card.Description class="line-clamp-1">
      {beer.brewery?.name ?? 'Unknown Brewery'}
    </Card.Description>
  </Card.Header>
  
  <Card.Content class="pb-2">
    <div class="flex gap-2 text-sm text-muted-foreground">
      <span class="bg-amber-100 px-2 py-0.5 rounded">{beer.style}</span>
      {#if beer.abv}
        <span>{beer.abv}% ABV</span>
      {/if}
    </div>
  </Card.Content>
  
  {#if showActions}
    <Card.Footer>
      <Button 
        class="w-full" 
        onclick={() => onAdd?.(beer)}
      >
        Add to Collection
      </Button>
    </Card.Footer>
  {/if}
</Card.Root>

CollectionItem

Displays a collection item with quantity and actions:

Svelte
<!-- src/components/collection/CollectionItem.svelte -->
<script>
  import * as Card from '$lib/components/ui/card';
  import { Button } from '$lib/components/ui/button';
  import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
  
  let { item, onConsume, onEdit, onRemove } = $props();
</script>

<Card.Root class="flex">
  <div class="w-24 h-24 flex-shrink-0">
    <img 
      src={item.beer.imageUrl ?? '/placeholder-beer.svg'} 
      alt={item.beer.name}
      class="object-cover w-full h-full rounded-l-lg"
    />
  </div>
  
  <div class="flex-1 p-4">
    <div class="flex justify-between items-start">
      <div>
        <h3 class="font-semibold">{item.beer.name}</h3>
        <p class="text-sm text-muted-foreground">
          {item.beer.brewery?.name}
        </p>
      </div>
      
      <DropdownMenu.Root>
        <DropdownMenu.Trigger asChild let:builder>
          <Button variant="ghost" size="icon" builders={[builder]}>

          </Button>
        </DropdownMenu.Trigger>
        <DropdownMenu.Content>
          <DropdownMenu.Item onclick={() => onConsume?.(item)}>
            Consume
          </DropdownMenu.Item>
          <DropdownMenu.Item onclick={() => onEdit?.(item)}>
            Edit
          </DropdownMenu.Item>
          <DropdownMenu.Separator />
          <DropdownMenu.Item 
            class="text-destructive"
            onclick={() => onRemove?.(item)}
          >
            Remove
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Root>
    </div>
    
    <div class="mt-2 flex gap-4 text-sm">
      <span class="font-medium">
        {item.quantity} {item.containerType}{item.quantity > 1 ? 's' : ''}
      </span>
      {#if item.storageLocation}
        <span class="text-muted-foreground">
          📍 {item.storageLocation}
        </span>
      {/if}
    </div>
  </div>
</Card.Root>

StarRating

Interactive star rating component:

Svelte
<!-- src/components/ratings/StarRating.svelte -->
<script>
  /**
   * @typedef {Object} Props
   * @property {number} value - Current rating value (0-5)
   * @property {boolean} [readonly=false] - If true, rating cannot be changed
   * @property {'sm' | 'md' | 'lg'} [size='md'] - Star size
   */
  
  /** @type {Props} */
  let { value = $bindable(0), readonly = false, size = 'md' } = $props();
  
  let hoverValue = $state(0);
  
  const sizes = {
    sm: 'w-4 h-4',
    md: 'w-6 h-6',
    lg: 'w-8 h-8'
  };
  
  function handleClick(rating) {
    if (!readonly) {
      value = rating;
    }
  }
  
  function handleMouseEnter(rating) {
    if (!readonly) {
      hoverValue = rating;
    }
  }
  
  function handleMouseLeave() {
    hoverValue = 0;
  }
</script>

<div 
  class="flex gap-1"
  role="radiogroup"
  aria-label="Rating"
  onmouseleave={handleMouseLeave}
>
  {#each [1, 2, 3, 4, 5] as rating}
    <button
      type="button"
      class="star {sizes[size]} {readonly ? 'cursor-default' : 'cursor-pointer'}"
      onclick={() => handleClick(rating)}
      onmouseenter={() => handleMouseEnter(rating)}
      aria-label="{rating} star{rating > 1 ? 's' : ''}"
      disabled={readonly}
    >
      <svg 
        viewBox="0 0 24 24" 
        fill={(hoverValue || value) >= rating ? 'currentColor' : 'none'}
        stroke="currentColor"
        class="{(hoverValue || value) >= rating ? 'text-amber-400' : 'text-gray-300'}"
      >
        <path 
          stroke-linecap="round" 
          stroke-linejoin="round" 
          stroke-width="2" 
          d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
        />
      </svg>
    </button>
  {/each}
</div>

<style>
  .star:focus {
    outline: 2px solid var(--ring);
    outline-offset: 2px;
    border-radius: 0.25rem;
  }
</style>

PhotoUpload

Image upload component with preview:

Svelte
<!-- src/components/storage/PhotoUpload.svelte -->
<script>
  import { Button } from '$lib/components/ui/button';
  
  /**
   * @type {{ photos: string[], maxFiles?: number }}
   */
  let { photos = $bindable([]), maxFiles = 5 } = $props();
  
  let fileInput = $state(null);
  let isUploading = $state(false);
  
  async function handleFiles(event) {
    const files = Array.from(event.target.files);
    if (photos.length + files.length > maxFiles) {
      alert(`Maximum ${maxFiles} photos allowed`);
      return;
    }
    
    isUploading = true;
    
    for (const file of files) {
      // Resize and convert to base64
      const base64 = await resizeImage(file, 800, 800);
      photos = [...photos, base64];
    }
    
    isUploading = false;
  }
  
  function removePhoto(index) {
    photos = photos.filter((_, i) => i !== index);
  }
  
  async function resizeImage(file, maxWidth, maxHeight) {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        const canvas = document.createElement('canvas');
        let { width, height } = img;
        
        if (width > maxWidth || height > maxHeight) {
          const ratio = Math.min(maxWidth / width, maxHeight / height);
          width *= ratio;
          height *= ratio;
        }
        
        canvas.width = width;
        canvas.height = height;
        
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);
        
        resolve(canvas.toDataURL('image/jpeg', 0.8));
      };
      img.src = URL.createObjectURL(file);
    });
  }
</script>

<div class="space-y-4">
  <!-- Photo grid -->
  {#if photos.length > 0}
    <div class="grid grid-cols-3 gap-2">
      {#each photos as photo, index}
        <div class="relative aspect-square">
          <img 
            src={photo} 
            alt="Upload {index + 1}" 
            class="w-full h-full object-cover rounded-lg"
          />
          <button
            type="button"
            class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full"
            onclick={() => removePhoto(index)}
          >
            ×
          </button>
        </div>
      {/each}
    </div>
  {/if}
  
  <!-- Upload button -->
  {#if photos.length < maxFiles}
    <div>
      <input
        type="file"
        accept="image/*"
        multiple
        bind:this={fileInput}
        onchange={handleFiles}
        class="hidden"
      />
      <Button 
        type="button"
        variant="outline"
        onclick={() => fileInput?.click()}
        disabled={isUploading}
      >
        {isUploading ? 'Uploading...' : 'Add Photos'}
      </Button>
      <p class="text-sm text-muted-foreground mt-1">
        {photos.length}/{maxFiles} photos
      </p>
    </div>
  {/if}
</div>

Component Patterns

Compound Components

Use compound components for complex UI structures:

Svelte
<!-- Usage -->
<Tabs.Root value="details">
  <Tabs.List>
    <Tabs.Trigger value="details">Details</Tabs.Trigger>
    <Tabs.Trigger value="reviews">Reviews</Tabs.Trigger>
    <Tabs.Trigger value="history">History</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="details">...</Tabs.Content>
  <Tabs.Content value="reviews">...</Tabs.Content>
  <Tabs.Content value="history">...</Tabs.Content>
</Tabs.Root>

Controlled vs Uncontrolled

Components support both patterns:

Svelte
<!-- Uncontrolled (internal state) -->
<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Content>...</Dialog.Content>
</Dialog.Root>

<!-- Controlled (external state) -->
<script>
  let isOpen = $state(false);
</script>

<Dialog.Root bind:open={isOpen}>
  <Dialog.Content>...</Dialog.Content>
</Dialog.Root>

<Button onclick={() => isOpen = true}>Open</Button>

Forwarding Props

Forward classes and attributes to underlying elements:

Svelte
<script>
  let { class: className, ...rest } = $props();
</script>

<button class="base-styles {className}" {...rest}>
  <slot />
</button>

Accessibility

All components follow WCAG 2.1 AA guidelines:

  • Proper ARIA attributes
  • Keyboard navigation support
  • Focus management
  • Screen reader announcements
  • Sufficient color contrast

Focus Trap Example

Svelte
<script>
  import { createFocusTrap } from '$lib/utils/accessibility.js';
  
  let dialogRef = $state(null);
  
  $effect(() => {
    if (dialogRef) {
      const trap = createFocusTrap(dialogRef);
      return () => trap.destroy();
    }
  });
</script>

<div bind:this={dialogRef} role="dialog" aria-modal="true">
  <!-- Dialog content -->
</div>

Theming

Components use CSS custom properties for theming:

CSS
/* src/app.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 38 92% 50%;
  --primary-foreground: 0 0% 100%;
  /* ... */
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  /* ... */
}

Next Steps