Components Cards & Layout ExpandingCard

ExpandingCard

Card that smoothly expands into detail.

Live demo

01
Database Connected

Default β€” 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
ExpandingCard.svelte
<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

03

What 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 β€” $state and $props, plus the built-in crossfade transition from svelte/transition. No extra runtime cost β€” crossfade is 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 β€” crossfade is 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                                # ExpandingCardProps

API

04
PropTypeDefaultDescription
imageSrcstringplaceholderURL of the card image.
imageAltstring'Card Image'Alt text for the image.
headingstring'Card Title'Card heading text.
compactTextstring'Hello Devs...'Text shown in compact layout.
expandedTextstring'Yoo devs...'Text shown in expanded layout.
bgColorstring'bg-lime-100'Tailwind background class. Database values must be present in your CSS or safelist.