ExpandingCard
Card that smoothly expands into detail.
Live demo
01Default β square cover image
The flagship configuration: ~1:1 image, short compact body, crossfade on click.
Database content
Cards loaded from Neon (or fallback constants when DATABASE_URL is absent).
Background colours
Same shape, four palette swaps via Tailwind bgColor.
Landscape image (16:9)
Forced 16:9 aspect via a wider crop β the card still composes correctly because the
inner img uses fixed Tailwind sizing that crops to fit.
Portrait image (9:16)
Same component, an explicit 9:16 source. Useful for posters, magazine covers and mobile-first hero imagery.
Narrow column (320px)
The same card mounted inside a 320px-wide wrapper, simulating a sidebar or a phone viewport. The inner Tailwind responsive classes keep the card legible.
Long-form expanded copy
Click to expand and see how a paragraph-length expandedText wraps inside
the horizontal layout.
Implementation
02<script>
import ExpandingCard from '$lib/components/ExpandingCard.svelte';
</script>
β
<ExpandingCard
imageSrc="https://example.com/image.jpg"
imageAlt="Description"
heading="Card Title"
compactText="Short preview text"
expandedText="Detailed description with more information"
bgColor="bg-lime-100"
/>ExpandingCard uses Svelte's built-in crossfade transition to morph the image, heading and copy between a compact (vertical) and expanded (horizontal) layout. Each ExpandingCard owns its open state β click the card to toggle.
Logic explainer
03What Does It Do? (Plain English)
ExpandingCard is a card that morphs between two layouts when clicked: a tall, vertical "compact" form with a square image above its heading, and a wide, horizontal "expanded" form with the image to the left of a longer body of text. On narrow containers the expanded form stacks again so the card stays inside its parent instead of clipping. The image, heading, and text don't fade in and out β they fly between positions. Click the card again and they fly back.
Think of it like a business card folding open: it's the same card, the same content, just rearranged with a smooth animation linking the two states.
How It Works (Pseudo-Code)
state:
layout β { 'compact', 'expanded' } // starts at 'compact'
send, receive = crossfade({ duration: 400 }) // a paired transition
events:
on click card:
layout = (layout === 'compact') ? 'expanded' : 'compact'
render:
{#if layout === 'compact'}
<div class="layout-a compact">
<img out:send={{ key: 'image' }} in:receive={{ key: 'image' }} />
<h3 out:send={{ key: 'heading' }} in:receive={{ key: 'heading' }} />
<p out:send={{ key: 'text' }} in:receive={{ key: 'text' }}>{compactText}</p>
</div>
{:else}
<div class="layout-a expanded">
<img out:send={{ key: 'image' }} in:receive={{ key: 'image' }} />
<h3 out:send={{ key: 'heading' }} in:receive={{ key: 'heading' }} />
<p out:send={{ key: 'text' }} in:receive={{ key: 'text' }}>{expandedText}</p>
</div>
{/if}Both layouts mount the same three elements with the same three crossfade keys. When layout flips, Svelte tears down the old layout, builds the new one, and the crossfade pairs each "leaving" element with its "arriving" twin by key β animating from the old position to the new in 400 ms.
The Core Concept: Crossfade Transitions
Svelte's crossfade is a paired transition factory. It returns two functions, send and receive, that look up each other's bounding rects by a shared key:
import { crossfade } from 'svelte/transition';
const [send, receive] = crossfade({ duration: 400 });When an element with out:send={{ key: 'image' }} leaves the DOM, Svelte parks its rect under the key 'image'. When an element with in:receive={{ key: 'image' }} enters, Svelte looks up that parked rect and animates the new element from the parked rect to its own real rect. The result: the image appears to fly between layouts, even though they're entirely different DOM subtrees.
Layout A (compact) Layout B (expanded)
βββββββββββββββββββ ββββββββββββββββββββββββββββββ
β βββββββββββ β β βββββββββββ β
β β IMAGE β β click β β IMAGE β HEADING β
β β key:img β β ββββββββββββββΆ β β key:img β Long body textβ
β βββββββββββ β β βββββββββββ that explains β
β HEADING β ββββββββββββββββββββββββββββββ
β short body β
βββββββββββββββββββThe two IMAGE boxes in the diagram are different DOM nodes β but because they share key: 'image', the crossfade animates the transition between their geometries.
Why the keys must match exactly
If layout A's image has key 'img' and layout B's image has key 'image', the crossfade has nothing to pair them with. Both elements fall back to a plain fade β A fades out from its old position, B fades in at its new one. The flying-between-layouts effect is gone. Always copy the key strings exactly.
The Importance of Absolute Positioning
The whole crossfade illusion relies on the outgoing element still occupying its old screen position long enough to be measured. If the layouts are normal-flow blocks, the outgoing element leaves a gap, the new layout reflows, and the geometry is wrong by the time receive fires.
.layout-a {
position: absolute;
inset: 0;
}The two layouts stack on top of each other in the same container. Both have position: absolute, so neither contributes to the parent's layout flow. When layout A is being torn down, it's still painted at its old position; when layout B mounts, it does so on top, and the crossfade has clean rects on both sides.
Without position: absolute, the same code would produce a noticeable jump where the parent collapses around the new layout's natural size.
CSS Animation Strategy
Crossfade does the heavy lifting; the rest is presentation. The card itself uses TailwindCSS for layout (bg-lime-100, flex flex-row gap-4), so it picks up the colour and spacing tokens of the host app rather than imposing its own. The 400 ms duration is hard-coded in the crossfade options β short enough to feel responsive, long enough that the eye can follow the image flying between positions.
prefers-reduced-motion: reduce is honoured by Svelte's transition system itself: when the user preference is set, transitions complete in 0 ms and the layout swap becomes an instant cut. No additional code path is needed.
State Flow Diagram
ββββββββββββββββββββ
β COMPACT β
β layout = 'compact' β
β layout-a renders β
β vertical card β
βββββββββββ¬ββββββββββββ
β
click card βΌ
ββββββββββββββββββββββββ
β TRANSITION OUT β
β layout-a (compact) β
β out:send for img, β
β heading, text β
βββββββββββ¬βββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β TRANSITION IN β
β layout-a (expanded) β
β in:receive matches β
β by key β elements β
β fly from old rects β
β to new rects β
βββββββββββ¬βββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β EXPANDED β
β layout = 'expanded' β
β horizontal card β
β expandedText shown β
βββββββββββ¬βββββββββββββ
β
click card βΌ
ββββββββββββββββββββββββ
β reverse direction: β
β expanded β compact β
ββββββββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
imageSrc |
string |
'https://i.pinimg.com/564x/b3/7c/fa/b37cfa52ac8e142ffe42772712f6e33d.jpg' |
URL of the card image. |
imageAlt |
string |
'Card Image' |
Alt text for the image. |
heading |
string |
'Card Title' |
Main heading text. Same in both layouts. |
compactText |
string |
'Hello Devs, welcome to our Website' |
Body text shown in compact (vertical) layout. |
expandedText |
string |
'Yoo devs, How you doing?' |
Body text shown in expanded (horizontal) layout. Typically longer. |
bgColor |
string |
'bg-lime-100' |
TailwindCSS background class applied to the card. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Rapid clicking before the 400 ms transition completes | Each click flips layout. The crossfade re-targets mid-flight, so the elements smoothly redirect to the new destination instead of jumping. |
imageSrc 404s |
The browser shows the broken-image fallback inside an otherwise-functional layout. The crossfade still flies the empty image box between positions. |
compactText shorter than expandedText |
Expected β the whole point. Both texts share the 'text' crossfade key, so the longer text appears to grow out of the shorter one. |
User has prefers-reduced-motion: reduce |
Svelte's transition system treats the duration as 0; the layout swap becomes an instant cut with no flying motion. |
bgColor set to a non-Tailwind value |
Tailwind's JIT will not generate the class. Pass a valid Tailwind class, or wrap the card in your own background container. |
Card placed inside a parent with conflicting position: relative rules |
Both layouts have position: absolute; inset: 0; so they fill the nearest positioned ancestor. Make sure the parent has a known size, or the card collapses. |
| Many ExpandingCards on the same page | Each component has its own crossfade instance; keys are scoped per-component, so cards animate independently without cross-talk. |
Dependencies
- Svelte 5.x β
$stateand$props, plus the built-incrossfadetransition fromsvelte/transition. No extra runtime cost βcrossfadeis part of the framework. - TailwindCSS β used for layout utilities (
flex,gap-4,bg-lime-100). The component assumes Tailwind is configured in the host app. - Zero external animation libraries β
crossfadeis pure Svelte.
File Structure
src/lib/components/ExpandingCard.svelte # implementation
src/lib/components/ExpandingCard.md # this file
src/lib/components/ExpandingCard.test.ts # vitest unit tests
src/routes/expandingcard/+page.svelte # demo page
src/routes/expandingcard/+page.server.ts # SSR data loader
src/lib/types.ts # ExpandingCardPropsAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
imageSrc | string | placeholder | URL of the card image. |
imageAlt | string | 'Card Image' | Alt text for the image. |
heading | string | 'Card Title' | Card heading text. |
compactText | string | 'Hello Devs...' | Text shown in compact layout. |
expandedText | string | 'Yoo devs...' | Text shown in expanded layout. |
bgColor | string | 'bg-lime-100' | Tailwind background class. Database values must be present in your CSS or safelist. |