KKUONIDesign System

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.

Featured destinations
1
Safari
Kenya & Tanzania
East Africa
Beach
Maldives
Indian Ocean
Holidays — 3-column grid
2
Maasai Mara Safari
10 nights
from £3,299 pp
Serengeti Explorer
14 nights
from £4,199 pp
Zanzibar Beach Retreat
7 nights
from £2,199 pp
Amboseli & Tsavo
12 nights
from £3,699 pp
Kenya Highlights
9 nights
from £2,899 pp
Gorilla Trekking Uganda
8 nights
from £3,499 pp
Holidays — 2-column grid (tablet / narrow)
3
Maasai Mara Safari
from £3,299 pp
Serengeti Explorer
from £4,199 pp
Zanzibar Beach Retreat
from £2,199 pp
Amboseli & Tsavo
from £3,699 pp
Loading state — skeleton
4
Empty state — no results
5
No holidays found
Try removing a filter or broadening your search
Clear all filters
  1. 1
    Featured destinations (2-col)Large DestinationCards at the top of the browse page. Editorially curated to surface the most relevant destinations for the current context.
  2. 2
    Holiday grid — 3-colDefault layout for TripCards on desktop. Used when showing 6 or more holidays across a broad category.
  3. 3
    Holiday grid — 2-colUsed when fewer than 6 results are available, or in a narrower column context (a sidebar widget, tablet viewport, or filtered sub-category).
  4. 4
    Skeleton loading stateAnimated grey placeholder cards shown during data fetch. Match the dimensions of the real cards to prevent layout shift.
  5. 5
    Empty stateShown 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 and variant="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:

ResultsDesktop colsTablet colsMobile cols
1 – 2211
3 – 5221
6+321

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:

  1. Match card dimensions exactly — aspect-ratio and height of the real card
  2. Use the pulse animationanimate-pulse on all skeleton blocks
  3. Replace the entire grid — not fade in on top of old results
  4. 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

  1. IconAlertCircle or Search from lucide-react, h-12 w-12 text-grey-300
  2. Heading"No holidays found" in text-lg font-semibold text-grey-600
  3. Body — one sentence explaining why and what to do: "Try removing a filter or broadening your search"
  4. CTA — primary Button: "Clear all filters" (if filters active) or "Browse all holidays" (if no results at all)

Empty state by context

ContextHeadingCTA
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

Do

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.

Don't

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.

Do

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.

Don't

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.

Do

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.

Don't

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.

Do

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.

Don't

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

ViewportDestinationCardTripCard (6+)TripCard (< 6)
< 640 px1-col1-col1-col
640–1023 px2-col2-col2-col
≥ 1024 px2-col3-col2-col

Never let DestinationCards go to 3-col on any viewport — the photography-to-data ratio breaks at that width.

Components used

ComponentRole in pattern
DestinationCardEditorial destination tile — inspiration tier
TripCardBookable holiday card — booking tier
BadgeHoliday type or quality label on card images
QuickFilterBarType / level / month filter above the grid
SortDropdownResult 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.