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
Choose your reading theme.
Six listing cards (a recipe, rentals, homes for sale) with hand-painted photos that swipe like a carousel.
listing-cards/listing-cards.tsxJuicy chicken roasted with lemon, garlic and sumac, served over warm, fragrant rice.
$3,679 /m
Bright, calm, spacious, central...
$250,000 List price
Corentin Vallois - Mila lindenbergerstr, 1063 AG
$200k
235 Fernhill Ave, Los...
Mila lindenhofstr...
1063 AG Corentin Vallois
By Idris Malouf 2 days ago
58 Ashbrook Road, Ellisley, B17 0LA
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
When not to use
You can add this component with the shadcn CLI or by hand:
Into components/listing-cards of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/listing-cards.jsonCopy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
As shown under Usage, below.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultLiked | boolean | false | Whether the flat starts saved. |
defaultReserved | boolean | — | 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. |
autoplay | boolean | true | While 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. |
className | string | — | 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.
// 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 (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'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,'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'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 () => {/* * 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"'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 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;