Theme
← Examples
Hotel Search
A faceted search experience with filters, pagination, ratings, and inventory cards.
apps/docs/src/lib/demos/HotelSearchDemo.svelte
Preview
Open in new tabHotel Search
8 hotels found
Coastal Breeze Motel
Cape Town, South AfricaBudget-friendly ocean-view rooms just steps from the beach.
WiFiParking
Alpine Retreat Lodge
Innsbruck, AustriaCozy mountain lodge surrounded by pristine alpine scenery.
WiFiGymRestaurantParking
Sakura Garden Inn
Kyoto, JapanTraditional ryokan experience with beautifully manicured gardens.
WiFiSpaRestaurant
Midtown Loft Hotel
New York, USAModern loft-style rooms in the heart of Manhattan.
WiFiGymParking
Source
This page is rendered from the real demo file shown below.
svelte
<script lang="ts">
import {
Badge,
Button,
Card,
Checkbox,
Field,
Flex,
Grid,
Input,
Label,
Pagination,
Rating,
Select,
Separator,
Slider,
Stack,
} from '@dryui/ui';
import { SvelteSet } from 'svelte/reactivity';
// --- Types ---
type Amenity = 'WiFi' | 'Pool' | 'Spa' | 'Gym' | 'Restaurant' | 'Parking';
type SortOption = 'price-asc' | 'price-desc' | 'rating' | 'name';
interface Hotel {
id: number;
name: string;
location: string;
pricePerNight: number;
rating: number;
amenities: Amenity[];
description: string;
}
// --- Mock data ---
const allHotels: Hotel[] = [
{
id: 1,
name: 'Grand Riviera Palace',
location: 'Barcelona, Spain',
pricePerNight: 320,
rating: 4.5,
amenities: ['WiFi', 'Pool', 'Spa', 'Restaurant'],
description: 'Elegant beachfront hotel with stunning Mediterranean views.',
},
{
id: 2,
name: 'Alpine Retreat Lodge',
location: 'Innsbruck, Austria',
pricePerNight: 185,
rating: 4,
amenities: ['WiFi', 'Gym', 'Restaurant', 'Parking'],
description: 'Cozy mountain lodge surrounded by pristine alpine scenery.',
},
{
id: 3,
name: 'The Harbour Suite',
location: 'Sydney, Australia',
pricePerNight: 410,
rating: 5,
amenities: ['WiFi', 'Pool', 'Spa', 'Gym', 'Restaurant'],
description: 'Luxurious harbour-front suites with iconic Opera House views.',
},
{
id: 4,
name: 'Midtown Loft Hotel',
location: 'New York, USA',
pricePerNight: 275,
rating: 3.5,
amenities: ['WiFi', 'Gym', 'Parking'],
description: 'Modern loft-style rooms in the heart of Manhattan.',
},
{
id: 5,
name: 'Sakura Garden Inn',
location: 'Kyoto, Japan',
pricePerNight: 230,
rating: 4.5,
amenities: ['WiFi', 'Spa', 'Restaurant'],
description: 'Traditional ryokan experience with beautifully manicured gardens.',
},
{
id: 6,
name: 'Desert Dune Resort',
location: 'Dubai, UAE',
pricePerNight: 495,
rating: 5,
amenities: ['WiFi', 'Pool', 'Spa', 'Gym', 'Restaurant', 'Parking'],
description: 'Ultra-luxury desert resort with infinity pools and private villas.',
},
{
id: 7,
name: 'Coastal Breeze Motel',
location: 'Cape Town, South Africa',
pricePerNight: 120,
rating: 3,
amenities: ['WiFi', 'Parking'],
description: 'Budget-friendly ocean-view rooms just steps from the beach.',
},
{
id: 8,
name: 'Palazzo Venezia',
location: 'Venice, Italy',
pricePerNight: 360,
rating: 4,
amenities: ['WiFi', 'Restaurant', 'Spa'],
description: 'Historic palazzo hotel along the Grand Canal with stunning architecture.',
},
];
const amenityOptions: Amenity[] = ['WiFi', 'Pool', 'Spa', 'Gym', 'Restaurant', 'Parking'];
const amenityColors: Record<Amenity, 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'gray'> = {
WiFi: 'blue',
Pool: 'purple',
Spa: 'green',
Gym: 'orange',
Restaurant: 'red',
Parking: 'gray',
};
// --- Search state ---
let destination = $state('');
let checkIn = $state('');
let checkOut = $state('');
let guests = $state('2');
// --- Filter state ---
let maxPrice = $state(500);
let minRating = $state(0);
let selectedAmenities = new SvelteSet<Amenity>();
let sortBy = $state<SortOption>('price-asc');
// --- Pagination state ---
let currentPage = $state(1);
const pageSize = 4;
function toggleAmenity(amenity: Amenity) {
if (selectedAmenities.has(amenity)) {
selectedAmenities.delete(amenity);
} else {
selectedAmenities.add(amenity);
}
currentPage = 1;
}
function handleSearch() {
currentPage = 1;
}
// --- Derived: filtered + sorted hotels ---
let filteredHotels = $derived.by(() => {
let results = allHotels.filter((h) => {
if (destination && !h.location.toLowerCase().includes(destination.toLowerCase()) && !h.name.toLowerCase().includes(destination.toLowerCase())) {
return false;
}
if (h.pricePerNight > maxPrice) return false;
if (h.rating < minRating) return false;
if (selectedAmenities.size > 0) {
for (const a of selectedAmenities) {
if (!h.amenities.includes(a)) return false;
}
}
return true;
});
return [...results].sort((a, b) => {
if (sortBy === 'price-asc') return a.pricePerNight - b.pricePerNight;
if (sortBy === 'price-desc') return b.pricePerNight - a.pricePerNight;
if (sortBy === 'rating') return b.rating - a.rating;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
});
let totalPages = $derived(Math.max(1, Math.ceil(filteredHotels.length / pageSize)));
let pagedHotels = $derived.by(() => {
const start = (currentPage - 1) * pageSize;
return filteredHotels.slice(start, start + pageSize);
});
</script>
<Stack gap="lg">
<h2>Hotel Search</h2>
<!-- Search bar -->
<Card.Root>
<Card.Content>
<Flex gap="md" align="end" wrap="wrap">
<Field.Root class="field-destination">
<Label>Destination</Label>
<Input bind:value={destination} placeholder="City, region, or hotel name…" />
</Field.Root>
<Field.Root class="field-date">
<Label>Check-in</Label>
<Input type="date" bind:value={checkIn} />
</Field.Root>
<Field.Root class="field-date">
<Label>Check-out</Label>
<Input type="date" bind:value={checkOut} />
</Field.Root>
<Field.Root class="field-guests">
<Label>Guests</Label>
<Input bind:value={guests} type="number" />
</Field.Root>
<Button variant="solid" onclick={handleSearch}>Search</Button>
</Flex>
</Card.Content>
</Card.Root>
<!-- Body: filters + results -->
<Flex gap="lg" align="start">
<!-- Filters sidebar -->
<div class="sidebar">
<Card.Root>
<Card.Header>
<h3>Filters</h3>
</Card.Header>
<Card.Content>
<Stack gap="lg">
<!-- Price range -->
<Stack gap="sm">
<Flex justify="between" align="center">
<Label>Max price / night</Label>
<span class="filter-value">${maxPrice}</span>
</Flex>
<Slider bind:value={maxPrice} min={50} max={600} step={10} />
</Stack>
<Separator />
<!-- Minimum star rating -->
<Stack gap="sm">
<Label>Minimum rating</Label>
<Rating bind:value={minRating} max={5} allowHalf size="sm" />
</Stack>
<Separator />
<!-- Amenities -->
<Stack gap="sm">
<Label>Amenities</Label>
{#each amenityOptions as amenity (amenity)}
<label class="amenity-label">
<Checkbox
checked={selectedAmenities.has(amenity)}
onclick={() => toggleAmenity(amenity)}
size="sm"
/>
{amenity}
</label>
{/each}
</Stack>
<Separator />
<!-- Sort -->
<Stack gap="sm">
<Label>Sort by</Label>
<Select.Root bind:value={sortBy}>
<Select.Trigger>
<Button variant="outline" size="sm">
<Select.Value placeholder="Sort by…" />
</Button>
</Select.Trigger>
<Select.Content>
<Select.Item value="price-asc">Price: Low to High</Select.Item>
<Select.Item value="price-desc">Price: High to Low</Select.Item>
<Select.Item value="rating">Highest Rating</Select.Item>
<Select.Item value="name">Name A–Z</Select.Item>
</Select.Content>
</Select.Root>
</Stack>
</Stack>
</Card.Content>
</Card.Root>
</div>
<!-- Results -->
<Stack gap="md" class="results-pane">
<Flex justify="between" align="center">
<span class="results-count">
{filteredHotels.length} hotel{filteredHotels.length !== 1 ? 's' : ''} found
</span>
</Flex>
{#if pagedHotels.length === 0}
<Card.Root>
<Card.Content>
<p class="no-results">No hotels match your current filters. Try adjusting your search.</p>
</Card.Content>
</Card.Root>
{:else}
<Grid columns={2} gap="md">
{#each pagedHotels as hotel (hotel.id)}
<Card.Root>
<Card.Header>
<Stack gap="sm">
<h4 class="hotel-name">{hotel.name}</h4>
<span class="hotel-location">{hotel.location}</span>
</Stack>
</Card.Header>
<Card.Content>
<Stack gap="sm">
<Flex gap="sm" align="center">
<Rating value={hotel.rating} max={5} readonly allowHalf size="sm" />
<span class="rating-label">{hotel.rating}/5</span>
</Flex>
<p class="hotel-desc">{hotel.description}</p>
<Flex gap="sm" wrap="wrap">
{#each hotel.amenities as amenity (amenity)}
<Badge variant="soft" color={amenityColors[amenity]} size="sm">{amenity}</Badge>
{/each}
</Flex>
</Stack>
</Card.Content>
<Card.Footer>
<Flex justify="between" align="center">
<Stack gap="sm">
<span class="price">${hotel.pricePerNight}</span>
<span class="per-night">per night</span>
</Stack>
<Button variant="solid" size="sm">Book Now</Button>
</Flex>
</Card.Footer>
</Card.Root>
{/each}
</Grid>
<!-- Pagination -->
{#if totalPages > 1}
<Flex justify="center">
<Pagination.Root bind:page={currentPage} totalPages={totalPages}>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous>Previous</Pagination.Previous>
</Pagination.Item>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as p (p)}
<Pagination.Item>
<Pagination.Link page={p}>{p}</Pagination.Link>
</Pagination.Item>
{/each}
<Pagination.Item>
<Pagination.Next>Next</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</Flex>
{/if}
{/if}
</Stack>
</Flex>
</Stack>
<style>
h2 {
margin: 2rem 0 0.5rem;
font-size: var(--dry-text-2xl-size);
}
h3 {
margin: 0;
font-size: var(--dry-text-base-size);
font-weight: 600;
}
h4 {
margin: 0;
}
.sidebar {
width: 240px;
flex-shrink: 0;
}
:global(.field-destination) {
flex: 2;
min-width: 180px;
}
:global(.field-date) {
flex: 1;
min-width: 140px;
}
:global(.field-guests) {
min-width: 100px;
}
:global(.results-pane) {
flex: 1;
min-width: 0;
}
.hotel-name {
font-size: var(--dry-text-base-size);
font-weight: 600;
}
.hotel-location {
font-size: var(--dry-text-sm-size);
color: var(--dry-color-text-secondary);
}
.hotel-desc {
margin: 0;
font-size: var(--dry-text-sm-size);
color: var(--dry-color-text-secondary);
line-height: 1.5;
}
.rating-label {
font-size: var(--dry-text-sm-size);
color: var(--dry-color-text-secondary);
}
.price {
font-size: var(--dry-text-xl-size);
font-weight: 700;
color: var(--dry-color-text);
}
.per-night {
font-size: var(--dry-text-xs-size);
color: var(--dry-color-text-secondary);
}
.filter-value {
font-size: var(--dry-text-sm-size);
font-weight: 600;
color: var(--dry-color-text);
}
.amenity-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: var(--dry-text-sm-size);
cursor: pointer;
}
.results-count {
font-size: var(--dry-text-sm-size);
color: var(--dry-color-text-secondary);
}
.no-results {
margin: 0;
text-align: center;
color: var(--dry-color-text-secondary);
padding: 2rem 0;
}
</style>