Skip to content
Palette Neutral
Download ZIP

Neutral palette · 6.4 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_card_shell_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_card_shell_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Neutral
Version
1.0.0
Digest
Full digest
sha256-e095b64666589d10ac0801048ffab4cbc210f209fdb69fdd7fcb7001e6cd24cd
Card.svelte Svelte · 9.5 KB Raw
<script module lang="ts">
	import type { Snippet } from 'svelte';

	/** Surface style: a hairline on the page surface, the one featured elevation, or a raised fill. */
	export type CardVariant = 'outline' | 'elevated' | 'muted';
	/** Internal spacing: 16, 24 or 32 px around the header, body and footer. */
	export type CardPadding = 'sm' | 'md' | 'lg';
	/**
	 * Renders the card's title. With `href` set it is the card's one link, stretched over the whole
	 * card; without it the label is plain text. Call it inside your own heading.
	 */
	export type CardTitleLink = Snippet<[label: string]>;
</script>

<script lang="ts">
	interface Props {
		/** Body content. */
		children: Snippet;
		/** Image, video or illustration, framed at the top of the card. */
		media?: Snippet;
		/** Title area. Receives `link`: render the title with `{@render link('Title')}` inside your heading. */
		header?: Snippet<[link: CardTitleLink]>;
		/** Actions or metadata, pinned to the bottom of the card. */
		footer?: Snippet;
		variant?: CardVariant;
		padding?: CardPadding;
		/** Makes the title a link whose hit area covers the card. */
		href?: string;
		/** Root element: an article on its own, an li inside your list. */
		as?: 'article' | 'div' | 'li';
		/** CSS aspect-ratio for the media frame, e.g. "16 / 10", "1 / 1" or "auto". */
		mediaAspect?: string;
	}

	let {
		children,
		media,
		header,
		footer,
		variant = 'outline',
		padding = 'md',
		href,
		as = 'article',
		mediaAspect = '16 / 10'
	}: Props = $props();

	const variants: Record<CardVariant, string> = {
		outline: 'card-shell--outline',
		elevated: 'card-shell--elevated',
		muted: 'card-shell--muted'
	};
	const paddings: Record<CardPadding, string> = {
		sm: 'card-shell--pad-sm',
		md: 'card-shell--pad-md',
		lg: 'card-shell--pad-lg'
	};
</script>

{#snippet titleLink(label: string)}
	{#if href}
		<a {href} class="card-shell__link">{label}</a>
	{:else}
		{label}
	{/if}
{/snippet}

<svelte:element
	this={as}
	class={[
		'card-shell relative isolate flex h-full min-w-0 flex-col text-start text-sm/5.5 text-[var(--_muted)]',
		variants[variant],
		paddings[padding],
		href && 'card-shell--linked'
	]}
>
	{#if media}
		<div class="card-shell__media">
			<div class="card-shell__frame" style:--_aspect={mediaAspect}>
				{@render media()}
			</div>
		</div>
	{/if}

	<div class="card-shell__content flex min-w-0 flex-1 flex-col">
		{#if header}
			<div class="card-shell__header flex min-w-0 flex-col gap-2 break-words">
				{@render header(titleLink)}
			</div>
		{/if}

		<div class={['card-shell__body min-w-0 break-words *:not-first:mt-3', header && 'mt-3']}>
			{@render children()}
		</div>

		{#if footer}
			<!-- mt-auto pins the footer to the bottom, so footers line up across a row of cards. -->
			<div
				class="card-shell__footer mt-auto flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-2 pt-6 text-[13px]/[1.35] tabular-nums"
			>
				{@render footer()}
			</div>
		{/if}
	</div>
</svelte:element>

<style>
	/* Public tokens: set --card-shell-* on the card or any ancestor to retone it. */
	.card-shell {
		--_accent: var(--card-shell-accent, #18181b);
		--_on-accent: var(--card-shell-on-accent, #ffffff);
		--_ink: var(--card-shell-ink, #18181b);
		--_muted: var(--card-shell-muted, #52525b);
		--_hairline: var(--card-shell-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--card-shell-surface, #ffffff);
		--_raised: var(--card-shell-raised, #f4f4f5);
		--_meta: var(--card-shell-meta, #71717a);
		--_radius: var(--card-shell-radius, 16px);

		border-radius: var(--_radius);
		background-color: var(--_surface);
		transition-property: box-shadow, background-color, transform;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}

	/* Padding presets. The same value spaces the media from the title, so the plate and text read as one card. */
	.card-shell--pad-sm {
		--_pad: 16px;
	}
	.card-shell--pad-md {
		--_pad: 24px;
	}
	.card-shell--pad-lg {
		--_pad: 32px;
	}
	.card-shell__content {
		padding: var(--_pad);
	}

	/* Footer metadata sits on the tertiary tone; on the muted fill it keeps the body tone for 4.5:1. */
	.card-shell__footer {
		color: var(--_meta);
	}
	.card-shell--muted .card-shell__footer {
		color: var(--_muted);
	}

	/* Surfaces. Only one card in a group should be elevated: it is the featured one. */
	.card-shell--outline {
		box-shadow: 0 0 0 1px var(--_hairline);
	}
	.card-shell--elevated {
		box-shadow:
			0 0 0 1px var(--_hairline),
			0 1px 2px rgb(0 0 0 / 0.05),
			0 12px 40px rgb(0 0 0 / 0.08);
	}
	.card-shell--muted {
		background-color: var(--_raised);
	}

	/*
	 * The media sits on an 8 px plate inside the card rather than bleeding to its edge, so its
	 * corners share the card's centre: inner radius = card radius - 8 px, never below 2 px.
	 */
	.card-shell__media {
		padding: 8px 8px 0;
	}
	.card-shell__frame {
		position: relative;
		overflow: hidden;
		aspect-ratio: var(--_aspect);
		border-radius: max(2px, calc(var(--_radius) - 8px));
		background-color: var(--_raised);
	}
	.card-shell__frame > :global(*) {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}
	/* A hairline drawn over the media, so a pale image still has an edge against the card. */
	.card-shell__frame::after {
		content: '';
		position: absolute;
		inset: 0;
		border-radius: inherit;
		box-shadow: inset 0 0 0 1px var(--_hairline);
		pointer-events: none;
	}

	/*
	 * Overridable defaults sit in the components layer, so any Tailwind utility you put on your own
	 * markup (text size, margin, position, z-index) wins over them.
	 */
	@layer components {
		/* Defaults for plain markup in the header; they live in a layer below utilities. */
		.card-shell__header :global(:where(h1, h2, h3, h4, h5, h6)) {
			margin: 0;
			font-size: 1.125rem;
			line-height: 1.3;
			font-weight: 600;
			letter-spacing: -0.015em;
			color: var(--_ink);
			text-wrap: balance;
		}
		.card-shell--pad-sm .card-shell__header :global(:where(h1, h2, h3, h4, h5, h6)) {
			font-size: 1rem;
			line-height: 1.35;
			letter-spacing: -0.011em;
		}
		.card-shell--pad-lg .card-shell__header :global(:where(h1, h2, h3, h4, h5, h6)) {
			font-size: 1.25rem;
			line-height: 1.3;
			letter-spacing: -0.015em;
		}
		/* A p in the header is the eyebrow: small, uppercase, tracked, one tone below the body. */
		.card-shell__header :global(:where(p)) {
			margin: 0;
			font-size: 12px;
			line-height: 1.35;
			font-weight: 500;
			letter-spacing: 0.06em;
			text-transform: uppercase;
			color: var(--_muted);
			text-wrap: balance;
		}
		.card-shell__body :global(:where(p)) {
			margin-block: 0;
			text-wrap: pretty;
		}
		.card-shell__body :global(:where(strong)) {
			font-weight: 500;
			color: var(--_ink);
		}

		/*
		 * Other controls sit above the card link and keep their own click. Ancestors of the title link
		 * are left alone: a positioned heading would become the link's containing block and shrink it.
		 */
		.card-shell--linked
			:global(
				:where(
						a,
						button,
						input,
						select,
						textarea,
						summary,
						label,
						video[controls],
						audio[controls],
						[contenteditable],
						[tabindex]
					):not(.card-shell__link):not(:has(.card-shell__link))
			) {
			position: relative;
			z-index: 1;
		}

		/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
		.card-shell:dir(rtl) .card-shell__header :global(:where(h1, h2, h3, h4, h5, h6, p)) {
			letter-spacing: 0;
			text-transform: none;
		}
	}

	/*
	 * Linked cards: the title link's ::after covers the card, so the whole card is one click target
	 * and one tab stop.
	 */
	.card-shell__link {
		color: inherit;
		text-decoration-line: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3px;
		text-decoration-color: transparent;
	}
	.card-shell__link::after {
		content: '';
		position: absolute;
		inset: 0;
		z-index: 0;
		border-radius: var(--_radius);
	}
	/* Fallback ring on the link itself; replaced by the card ring where :has() is supported. */
	.card-shell__link:focus-visible {
		outline: 2px solid var(--_accent);
		outline-offset: 2px;
		border-radius: 2px;
	}

	/* Hover exists only where the click lands: on the title link's area, not on a footer action. */
	.card-shell--linked:has(:global(.card-shell__link:hover)) :global(.card-shell__link) {
		text-decoration-color: color-mix(in oklab, var(--_ink) 40%, transparent);
	}
	.card-shell--linked.card-shell--outline:has(:global(.card-shell__link:hover)) {
		box-shadow: 0 0 0 1px color-mix(in oklab, var(--_ink) 22%, transparent);
	}
	.card-shell--linked.card-shell--elevated:has(:global(.card-shell__link:hover)) {
		box-shadow:
			0 0 0 1px color-mix(in oklab, var(--_ink) 14%, transparent),
			0 2px 4px rgb(0 0 0 / 0.06),
			0 16px 48px rgb(0 0 0 / 0.12);
	}
	.card-shell--linked.card-shell--muted:has(:global(.card-shell__link:hover)) {
		background-color: color-mix(in oklab, var(--_raised), var(--_ink) 5%);
	}

	/* A press settles the card by 1%: a full 2% on a 400 px card would move its edge 4 px. */
	.card-shell--linked:has(:global(.card-shell__link:active)) {
		transform: scale(0.99);
		transition-duration: 80ms;
	}

	@supports selector(:has(*)) {
		.card-shell__link:focus-visible {
			outline: none;
		}
		.card-shell--linked:has(:global(.card-shell__link:focus-visible)) {
			outline: 2px solid var(--_accent);
			outline-offset: 2px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.card-shell--linked {
			transition-property: box-shadow, background-color;
		}
		.card-shell--linked:has(:global(.card-shell__link:active)) {
			transform: none;
		}
	}
</style>

Presentational only: the card frames whatever you pass in its snippets and never fetches, selects or stores anything. With href set, the title you render through the header's link snippet becomes the card's only link and its hit area covers the card. No selection, checkbox or drag behaviour, and no grid: place the cards in your own list or grid.

Suggested location
src/lib/components/card-shell-01
Required props
children

Limitations

  • No grid or list of its own: put cards in your own ul (with as="li") or grid. Grid items stretch, and the footer's auto margin then lines footers up across a row; in a flex row, keep the default align-items: stretch.
  • The header snippet must call link(title) for href to do anything; if it does not, the card renders no link.
  • On a linked card the stretched link covers the body text, so dragging to select that text starts a link drag instead. Keep long copy you expect readers to select on a static card.
  • Other controls in a linked card (links, buttons, form fields, summary, label, [tabindex], [contenteditable], media with controls) sit above the card link via position: relative; z-index: 1 in the components layer, so your utilities win. Don't position the heading holding the title link: it would shrink the link's hit area to the heading.
  • Footers align row by row through the auto margin; media, titles and bodies do not share lines across cards.
  • Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.

Example

Svelte
<script lang="ts">
	import Card from '$lib/components/card-shell-01/Card.svelte';
</script>

<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	<Card as="li" href="/templates/product-roadmap">
		{#snippet media()}
			<img src="/images/roadmap.png" alt="" />
		{/snippet}
		{#snippet header(link)}
			<p>Timeline template</p>
			<h3>{@render link('Product roadmap')}</h3>
		{/snippet}
		<p>Quarters across the top and one lane per team.</p>
		{#snippet footer()}
			<span>Timeline view</span>
			<span>4 lanes</span>
		{/snippet}
	</Card>
</ul>

Card shell#

A frame for one piece of content: optional media, a header, a body and a footer. It does not lay cards out; put them in your own list or grid.

A linked card#

Set href and render the title through the link snippet the header receives. The title becomes the card's only link, and its hit area covers the whole card. Footer links and buttons stay clickable above it.

Svelte
<Card href="/classes/sourdough" as="li">
	{#snippet header(link)}
		<p>Saturday class</p>
		<h3>{@render link('Sourdough at home')}</h3>
	{/snippet}
	<p>Feed a starter, shape two loaves and bake one in class.</p>
	{#snippet footer()}
		<span>£65 · 3 hours</span>
		<a href="/classes/sourdough/book">Book</a>
	{/snippet}
</Card>

Lining up a row#

Grid items stretch to the row's height and the card fills its cell, so the footer's auto margin puts every footer in a row on the same line:

Svelte
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	{#each items as item (item.href)}
		<Card as="li" href={item.href}>...</Card>
	{/each}
</ul>

Retoning for a dark page#

CSS
.dark-band {
	--card-shell-surface: #18181b;
	--card-shell-raised: #27272a;
	--card-shell-ink: #fafafa;
	--card-shell-muted: #a1a1aa;
	--card-shell-meta: #a1a1aa;
	--card-shell-hairline: rgb(255 255 255 / 0.1);
	--card-shell-accent: #fafafa;
}

Use the outline or muted variant there; a black shadow does not show on a dark page.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
childrenSnippetYesNoneBody content. Plain paragraphs get the card's body type and 12 px between them.
mediaSnippetNoNoneImage, video or illustration, framed at the top of the card on an 8 px plate. Its first element fills the frame with object-fit: cover. Omitted, no frame renders.
headerSnippet<[link: CardTitleLink]>NoNoneTitle area. Receives link, a snippet that renders the title: {@render link('Title')} inside your own heading, so you choose the heading level. With href it is the card's one link; without, plain text. A p here is set as a small uppercase eyebrow.
variant'outline' | 'elevated' | 'muted'No'outline'Surface: a hairline, the featured elevation (use it on one card in a group), or a raised fill with no line.
padding'sm' | 'md' | 'lg'No'md'Internal spacing of 16, 24 or 32 px. The default heading size steps with it: 16, 18 or 20 px.
hrefstringNoNoneMakes the title a link whose hit area covers the card, and turns on the hover, press and focus ring.
as'article' | 'div' | 'li'No'article'Root element. Use li inside your own ul.
mediaAspectstringNo'16 / 10'CSS aspect-ratio of the media frame, such as '4 / 3', '1 / 1' or 'auto' to follow the media.

Customization#

On this page

Fill the snippets with your own content, retone the card through nine --card-shell-* variables (accent, on-accent, ink, muted, meta, hairline, surface, raised and radius), and edit the source for spacing or layout.

  • Content: everything inside the card is yours. Plain h1 to h6 and p in the header, and p in the body, pick up the card's type from rules in the components cascade layer, so any Tailwind utility you add to them wins. A p in the header is set as an uppercase eyebrow; its tracking and case reset in right-to-left text.
  • Link: set href and render the title with {@render link('Title')} inside your heading. Keep other links and buttons in the footer; they stay clickable above the card link.
  • Accent: --card-shell-accent draws the linked card's focus ring. --card-shell-on-accent is there for a filled button you put in the footer, alongside the accent.
  • Text: --card-shell-ink sets headings; --card-shell-muted sets the body and the header eyebrow; --card-shell-meta (#71717a, 4.8:1 on white) sets footer metadata on outline and elevated cards. The muted variant keeps footer text on --card-shell-muted, because #71717a falls to 4.4:1 on its fill. Keep all three at 4.5:1 on the surface.
  • Surfaces: --card-shell-surface fills outline and elevated cards; --card-shell-raised fills the muted variant and sits behind media while it loads; --card-shell-hairline draws the outline and the edge over media.
  • Radius: --card-shell-radius sets the card's corners (16 px); the media frame follows at the radius minus 8 px, never below 2 px.
  • Dark page retone: surface #18181b, raised #27272a, ink #fafafa, muted #a1a1aa, meta #a1a1aa, hairline rgb(255 255 255 / 0.1), accent #fafafa (all --card-shell-*). The elevated shadow disappears on dark; use outline there. usage.md has the snippet.
  • Grid: the card fills its grid cell, so cards in a row share a height and their footers line up. Use as="li" inside a ul with role="list".
  • Media: pass an img with alt text if the image says something the title does not; otherwise alt="". Change mediaAspect to fit your images.

Public CSS variables

VariableToken
--card-shell-accentaccent
--card-shell-on-accentonAccent
--card-shell-inkink
--card-shell-mutedmuted
--card-shell-hairlinehairline
--card-shell-surfacesurface
--card-shell-raisedraised
--card-shell-radiusradius
--card-shell-metameta

Accessibility#

On this page
  • A linked card has exactly one link, the title, so its accessible name is the title rather than the whole card's text. The card is one tab stop plus one for each control you put in it.
  • The heading level is yours: render the title inside the heading that fits your page outline.
  • The linked card shows a two-pixel accent focus ring around the whole card, offset by two pixels, only when the title link is keyboard-focused (:has(:focus-visible)). Focus on a footer button shows that button's own ring, not the card's. Where :has() is unsupported the ring falls back to the link itself.
  • Hover, press and focus styling appear only on linked cards. The press scale is removed under prefers-reduced-motion.
  • Choose the root element to fit the content: article for a card that stands on its own, li inside a list of cards.
  • Media alt text is yours to write; decorative media should have alt="" or aria-hidden.
  • Muted text (#52525b) measures 7.7:1 on white and 7.0:1 on the muted fill (#f4f4f5). Footer metadata (#71717a) measures 4.8:1 on white; on the muted fill, where it would fall to 4.4:1, the footer uses the muted tone instead. Ratios use the WCAG relative-luminance formula.
  • The layout uses logical properties and text-start, so it mirrors under dir="rtl"; heading letter-spacing resets to 0 in right-to-left text.

Known limitations

  • Contrast is computed for the default tokens only; re-check any token you change (4.5:1 for text, 3:1 for the focus ring).
  • The outline hairline is a decorative boundary under 3:1; the card is identified by its content, and a linked card by its title link.
  • Text inside a linked card cannot be selected by dragging over it, because the link covers it.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.