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 formsshadcn-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-menuButton
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
- Forms - Form handling patterns
- UI Elements - Full shadcn-svelte reference
- Features - Feature implementation guides