Patterns
Destination Browsing
How DestinationCards, TripCards, and Badges compose into a browse page. Grid layout rules, when to use 2-col vs 3-col, and loading and empty state handling for the results grid.
- 1Featured destinations (2-col) — Large DestinationCards at the top of the browse page. Editorially curated to surface the most relevant destinations for the current context.
- 2Holiday grid — 3-col — Default layout for TripCards on desktop. Used when showing 6 or more holidays across a broad category.
- 3Holiday grid — 2-col — Used when fewer than 6 results are available, or in a narrower column context (a sidebar widget, tablet viewport, or filtered sub-category).
- 4Skeleton loading state — Animated grey placeholder cards shown during data fetch. Match the dimensions of the real cards to prevent layout shift.
- 5Empty state — Shown when a filter or search returns zero results. Includes explanatory text and a "Clear all filters" CTA.
Pattern overview
The Destination Browsing pattern composes content cards into a hierarchical browse page — broad destination categories at the top (DestinationCard) narrowing to specific bookable holidays below (TripCard).
It appears on:
- Destination overview pages — e.g.
/destinations/africa - Category pages — e.g.
/holidays/safari,/holidays/beach - Search results — when the search returns a mix of destination and trip results
- Homepage featured section — a curated 2-col DestinationCard row above the fold
The key design constraint is visual hierarchy: DestinationCards are large and editorial (destination photography, minimal data), while TripCards are information-dense (nights, price, rating, CTA). The two card types must never appear in the same grid row.
Card composition
DestinationCard — editorial tier
DestinationCards are the entry point to a destination or category. They:
- Are always editorially curated — not generated algorithmically from search results
- Use large photography (4:3 or 16:9) that conveys the destination's character
- Show minimal data: destination name, region, and a single Badge (holiday type)
- Link to a destination overview page, not directly to a bookable trip
TripCard — booking tier
TripCards are the primary browsable product unit. They:
- Appear in grids of 6 or more at the booking tier
- Show full booking signals: nights, board basis, star rating, price from, and a CTA
- Link directly to the holiday detail / enquiry page
- Use
variant="compact"in 3-column grids andvariant="default"in 2-column grids
Badge — contextual labelling
Badges are used on DestinationCards (always) and optionally on TripCards (for featured or award-winning properties):
- On DestinationCard: holiday type badge (
Safari,Beach,City) — top-left of image - On TripCard: award or quality badge (
Handpicked,Luxury,New) — top-right of image
Use a maximum of one Badge per card. Never use status-colour Badges (red, amber, green) on browse cards — those colours are reserved for system feedback.
Grid layout rules
When to use 3 columns
Use a 3-column TripCard grid when:
- There are 6 or more results to display
- The content column is ≥ 1024 px wide
- All cards are the same type (TripCard or ResortCard — never mixed)
grid-cols-3 gap-4 (≥ 1024 px)
grid-cols-2 gap-4 (640–1023 px)
grid-cols-1 gap-4 (< 640 px)
When to use 2 columns
Use a 2-column layout when:
- There are fewer than 6 results (a 3-col grid with 1 orphan card looks broken)
- The card grid appears in a sidebar or narrower column context
- The cards are DestinationCards (their image-heavy design reads poorly at 3-col)
grid-cols-2 gap-4 (≥ 640 px)
grid-cols-1 gap-4 (< 640 px)
Column switching rule
Determine the column count from the number of results, not the viewport alone:
| Results | Desktop cols | Tablet cols | Mobile cols |
|---|---|---|---|
| 1 – 2 | 2 | 1 | 1 |
| 3 – 5 | 2 | 2 | 1 |
| 6+ | 3 | 2 | 1 |
Never render a 3-col grid with fewer than 6 cards. A grid with 1 or 2 orphan cards in the last row looks unfinished. Switch to a 2-col grid when you cannot guarantee a full final row.
DestinationCard grid — always 2 columns
DestinationCards are editorial and image-led. Use 2 columns on all desktop viewports — 3-col makes the destination photography too small and forces awkward portrait crops.
Loading states
Use skeleton cards whenever the card data is being fetched. The skeleton should:
- Match card dimensions exactly — aspect-ratio and height of the real card
- Use the pulse animation —
animate-pulseon all skeleton blocks - Replace the entire grid — not fade in on top of old results
- Reflect the expected grid layout — show 6 skeleton cards in 3-col if the real result set will be 3-col
{/* Skeleton TripCard — 3-col grid */}
<div className="overflow-hidden rounded-md border border-grey-200 bg-white">
<div className="aspect-video animate-pulse bg-grey-200" />
<div className="p-3 space-y-2">
<div className="h-4 w-3/4 animate-pulse rounded bg-grey-200" />
<div className="h-3 w-1/2 animate-pulse rounded bg-grey-100" />
<div className="h-3 w-1/3 animate-pulse rounded bg-grey-200" />
</div>
</div>
Do not use a spinner for card grids — a skeleton maintains layout stability and gives users a preview of how many results are loading.
Empty states
An empty card grid must always include an explanation and a recovery action.
Components in an empty state
- Icon —
AlertCircleorSearchfrom lucide-react,h-12 w-12 text-grey-300 - Heading —
"No holidays found"intext-lg font-semibold text-grey-600 - Body — one sentence explaining why and what to do:
"Try removing a filter or broadening your search" - CTA — primary Button:
"Clear all filters"(if filters active) or"Browse all holidays"(if no results at all)
Empty state by context
| Context | Heading | CTA |
|---|---|---|
| Filter returns 0 results | "No holidays match your filters" | Clear all filters |
| Search keyword returns 0 | "No results for '[query]'" | Browse all holidays |
| Category has no trips yet | "No holidays in this category yet" | View all destinations |
Usage
Layer card types by hierarchy: DestinationCard → TripCard, with a clear visual break between the two tiers.
DestinationCards (large, editorial) above the fold, TripCards (data-dense, bookable) below — the visual weight guides users from inspiration to booking.
Place DestinationCards and TripCards in the same card grid row.
Mixing DestinationCards (no price, no nights) with TripCards (price, nights, rating) in the same grid forces users to compare incompatible data points.
Switch to 2-col when you have fewer than 6 results.
A 2-col grid with 4 cards has two rows of 2 — clean and balanced. A 3-col grid with 4 cards has one row of 3 and one orphan card — broken.
Render a 3-col grid when you have 4 or 5 results and cannot fill all cells.
Stretching a single card to full width or leaving obvious empty grid cells undermines the browse rhythm.
Show skeleton cards that match the final grid column count and approximate card height.
A skeleton at 3-col with 6 placeholders accurately previews the actual result layout — no layout shift when results load.
Show a page-level spinner in place of the results grid.
A spinning indicator gives no preview of how many results are loading, and the layout shift from spinner → grid is jarring.
Provide an actionable empty state with explanatory text and a recovery CTA.
'No holidays match your filters — try removing a filter or broadening your search' with a 'Clear all filters' button tells users exactly what to do.
Render an empty grid with no empty-state content.
An empty white area with no message looks like a loading error or a broken page — users will reload and bounce.
Responsive behaviour summary
| Viewport | DestinationCard | TripCard (6+) | TripCard (< 6) |
|---|---|---|---|
< 640 px | 1-col | 1-col | 1-col |
640–1023 px | 2-col | 2-col | 2-col |
≥ 1024 px | 2-col | 3-col | 2-col |
Never let DestinationCards go to 3-col on any viewport — the photography-to-data ratio breaks at that width.
Components used
| Component | Role in pattern |
|---|---|
| DestinationCard | Editorial destination tile — inspiration tier |
| TripCard | Bookable holiday card — booking tier |
| Badge | Holiday type or quality label on card images |
| QuickFilterBar | Type / level / month filter above the grid |
| SortDropdown | Result ordering control |
Accessibility
Landmark regions
Wrap the card grid in a <section aria-label="Holiday results"> landmark so screen reader users can navigate directly to it via the landmarks menu.
Card headings
Every TripCard and DestinationCard name is an <h3>. Ensure the page has a preceding <h2> section heading so the heading hierarchy is: h1 (page) → h2 (section) → h3 (card name).
Result count live region
When the grid updates after filtering, announce the new count:
<p aria-live="polite" aria-atomic="true">
31 holidays found
</p>
Skeleton loading
While results are loading, set aria-busy="true" on the grid container and aria-label="Loading results" on the skeleton wrapper. Remove both when data arrives.