Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Listing cards

New

Six listing cards (a recipe, rentals, homes for sale) with hand-painted photos that swipe like a carousel.

listing-cards/listing-cards.tsx
Live · interactive
Hot choice

Lebanese Lemon Garlic Chicken

Juicy chicken roasted with lemon, garlic and sumac, served over warm, fragrant rice.

  • 20 mins
  • 4 servings
  • Medium
  • Yogurt
  • Olive oil
  • +8
Guests Favorite

Business Bay

$3,679 /m

Bright, calm, spacious, central...

  • Beds: 2
  • Baths: 2
  • Sqft: 1150
Prime Pick

$250,000 List price

Corentin Vallois - Mila lindenbergerstr, 1063 AG

  • 29 m² Living
  • 2 Rooms
Newly listed

Highland Huts

$200k

235 Fernhill Ave, Los...

  • Beds: 2
  • Baths: 1
  • Sqft: 1150
Prime Pick

List: $250,000

Mila lindenhofstr...
1063 AG Corentin Vallois

29 m²
Living
2
Rooms

By Idris Malouf 2 days ago

The Hills Guest House

58 Ashbrook Road, Ellisley, B17 0LA

  • Beds: 4
  • Baths: 2
  • Sqft: 1150
$620

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A grid of things to browse and compare at a glance: homes, stays, recipes, products with photos.
  • Items whose photos sell them, where a few pictures per card save a trip to the detail page.
  • One action per card that can happen right there: save, reserve, start, view details.
  • Mixed kinds on one board, each card shaped by what it lists but drawn from one unit.

When not to use

  • Long lists to scan and sort by a figure. Use a table or a dense list with columns.
  • Items without good pictures: an empty photo box weighs more than a line of text.
  • Several actions on each item. Put them on the detail page, or in a menu on a row.
  • One item on its own page. Use a full gallery and the details laid out in full.

You can add this component with the shadcn CLI or by hand:

Into components/listing-cards of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/listing-cards.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

listings.tsx
import { ListingCards } from './listing-cards';export function Listings() {  return (    <ListingCards      autoplay={false}      onLikeChange={(liked) => console.log('saved', liked)}      onAction={(action) => console.log(action)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A board of six cards of different kinds: a recipe with its timings and ingredients, a flat to rent, a house for sale, and three photo cards with their details on a soft shade. Every photo is painted in SVG. The photos swipe, step with arrows or keys, and follow their dots. Cards lift under the pointer, the heart saves the flat, and the guest house can be reserved.

  • Every photo is a scene painted in SVG and drawn once, in a hidden sprite. Each slide shows a scene through a use element, whole or as a closer crop set by its viewBox. Leaves, grass and grains of rice come from a seeded random generator, so the server and the browser draw the same picture.
  • Each set of photos is a small carousel. A drag or swipe moves the shown slide and its neighbours with the hand, and past a fifth of the width, or after a flick, it moves on. The arrows wrap around, the arrow keys step up to either end, and the dots shrink when they are far from the photo shown.
  • Each card’s photo box paints on one fixed layer of its own. The slides inside it are absolutely placed boxes that glide frame by frame, so a turn never makes a layer wider than the photos and never repaints the page around it.
  • Every size is a multiple of one unit, set from the stage’s width by a container query, so the board scales as one drawing. Below 680px it turns to two columns, and below 440px to one.
  • The heart and the Reserve button each hold both of their states, drawn in place, and swap them by visibility. The heart pops with the Web Animations API, and a card’s deeper hover shadow fades in by its opacity.
  • Every interface colour is a light-dark() token on the root, so the cards follow the page’s color-scheme, or the colorScheme prop; the paintings, and what is drawn over them (badges, arrows, the heart, the price pill), stay as they are.
PropTypeDefaultDescription
defaultLikedbooleanfalseWhether the flat starts saved.
defaultReservedboolean—Whether the guest house opens already reserved. Setting it to true turns the demo off.
onLikeChange(liked: boolean) => void—Called when the reader saves the flat or takes it off their list.
onAction(action: ListingAction) => void—Called when the reader presses a card’s button: start-cooking, view-details, reserve or cancel-reservation.
autoplaybooleantrueWhile on screen and untouched, a demo pointer flips through photos and presses buttons.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

listing-cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type FocusEvent } from 'react';import { SceneSprite, SpriteContext } from './art';import type { CarouselHandle } from './carousel';import { CottageCard, FlatCard, GuestHouseCard, HouseCard, ListingCard, RecipeCard } from './cards';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './listing-cards.module.css';/* * Listing cards: six cards of different kinds on one board, a recipe, a flat to rent, a house for * sale, and three photo cards with their text on a shade. * * - The photos are painted in SVG (art.tsx): each scene is drawn once in a hidden sprite, and a *   slide shows it whole or as a closer crop, through its viewBox. * - Each card's photos are a small carousel (carousel.tsx): drag or swipe them, use the arrows *   that show on hover and on focus, or the arrow keys; the dots follow, and shrink when far from *   the photo shown. * - A card lifts under the pointer: it moves by its transform, and its deeper shadow fades in by *   its opacity, so nothing is painted again as it moves. * - The heart saves the flat and the guest house can be reserved: each swaps between two glyphs
listing-cards.module.css
/* Listing cards (listing-cards.tsx): six cards on a light grey stage, three by two; at night, on   a near-black stage, the three white cards turn a dark grey a step above it.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). The photos are paintings (art.tsx) and stay as   they are, and so does what lies on them, drawn against the pictures rather than the page: the   badges, arrows, dots and heart, and the photo cards' shades, words and buttons.   Every size is a multiple of --u, one unit of the cards' design, so the whole board scales with   its box as one drawing: the stage is a size container, and --u follows its width (up to 1px).   Narrow, the board turns to two columns, then one, and --u grows again so the text stays legible.   Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted   again gets a second buffer. So the board is one layout boundary of a set size (.scene), each   card's photos paint on a fixed layer of their own (their slides move inside it, frame by   frame, without becoming layers), and the cards lift by transform alone; the heart and the   button swap glyphs by visibility, and no drawing ever changes inside an SVG. */.stage {  /* The stage, and the cards on it. */  --surface: #f2f2f2;  --surface-2: #fff;  /* The words on the cards: titles and prices, bold figures and names, the flat's price, the
cards.tsx
'use client';import type { CSSProperties, Ref } from 'react';import { Photo } from './art';import { Carousel, type CarouselHandle } from './carousel';import {  AreaIcon,  BathIcon,  BedIcon,  CheckIcon,  ChefIcon,  ClockIcon,  ExpandIcon,  HeartIcon,  HouseIcon,  MapIcon,  PeopleIcon,  StarIcon,} from './icons';import styles from './listing-cards.module.css';/* * The six cards. Three sit on white (a dark grey at night) with their photos inset: a recipe, a * flat to rent, a house for sale. Three are photos from edge to edge, their text on a shade: a cottage, the house again,
carousel.tsx
'use client';import {  useEffect,  useImperativeHandle,  useRef,  useState,  type KeyboardEvent,  type PointerEvent,  type ReactNode,  type Ref,} from 'react';import { ChevronIcon } from './icons';import styles from './listing-cards.module.css';/* * A card's photos: slides that slide sideways. A finger or a mouse drags them and lets go to the * next photo or back; the arrows (on hover, or on focus) step through them and wrap around, and * the arrow keys step up to either end; the dots say where you are, and shrink when far from the * photo shown, as many apps do. * * Each slide is a box the size of the photos, stacked on the others and placed by its transform: * the one shown, its neighbours one width off to either side, the rest hidden. A drag moves the * shown one and its neighbours with the hand; a turn glides the two slides it swaps, frame by
art.tsx
'use client';import { createContext, memo, useContext, type ReactNode } from 'react';/* * The photos of the cards, painted in SVG: a dish on a dark table, a sunlit living room, a * farmhouse in tall grass, a cottage on a lawn, a stone guest house at dusk, and a second view of * each. Every scene is drawn once in its own units; a slide shows it whole or a closer crop of it * (its viewBox), and fills its box the way a photo would (slice). * * Each scene is drawn once, in a hidden sprite (SceneSprite), and every slide shows it through a * <use>, so a scene seen on two slides costs its markup once. Leaves, grass and rice are many small * shapes scattered by a seeded random generator, so the server and the browser draw the very same * picture, and merged into one path per colour. The drawings never change once drawn: a carousel * moves the boxes around them. *//** Names a scene's gradients and patterns, unique on the page. */type Ids = { id: (name: string) => string; url: (name: string) => string };/** A seeded random number generator: the same seed gives the same numbers everywhere. */function seeded(seed: number) {  let value = seed % 2147483647 || 1;  return () => {
icons.tsx
/* * The cards' small line icons, drawn on a 16 × 16 grid in the current colour. They are static * drawings: a state that changes an icon swaps whole glyphs around them instead of restyling one. */type IconProps = { className?: string };const LINE = {  fill: 'none',  stroke: 'currentColor',  strokeWidth: 1.25,  strokeLinecap: 'round',  strokeLinejoin: 'round',} as const;export function StarIcon({ className }: IconProps) {  return (    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">      <path        d="M8 1.6l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.4l-3.8 2 .7-4.3-3.1-3 4.3-.6z"        fill="#f5b51b"        stroke="#f5b51b"        strokeWidth="0.8"        strokeLinejoin="round"
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a control and press it, or grab a photo and drag it aside, calling the * component's own handlers as a hand would. Stopping the demo (the reader takes over, or it * scrolls away) ends the step in progress at once: its promise rejects, which ends the script * right there, and the pointer fades out. * * Nothing the pointer does while the demo plays needs a layout, because one here in the same * frame as one in the cards would make the browser lay out the whole page (see the note at the * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged * value follows that animation's clock); the press scales a box that keeps a transform at rest, * since a box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes * that swap by their visibility (a change of style inside an SVG lays the SVG out again). */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Each card is an article named by its title. Each set of photos is a group described as a carousel, its slides are labelled “2 of 4”, and every drawing is labelled with what it shows.
  • The Previous photo and Next photo buttons show on focus as well as on hover, and the arrow keys step through the photos. Vertical swipes still scroll the page (touch-action: pan-y).
  • The heart and Reserve are real toggle buttons with aria-pressed. Saving, reserving and turning a photo are announced in a polite live region; the demo’s own actions stay silent.
  • Keyboard focus anywhere on the stage stops the demo. Under reduced motion, turns jump and the demo never plays.