Skip to content
Download ZIP

Violet accent palette · 5.7 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_feature_card_01 · version 1.0.0 · Violet accent palette
Using the PageSugar MCP server, fetch component cmp_feature_card_01 version 1.0.0 with variant "violet", 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
Violet accent
Version
1.0.0
Digest
Full digest
sha256-bc82987a29bff38d44d11449eaa82493147e62400d9143861e8abf05b7d07f34
FeatureCard.svelte Svelte · 7.4 KB Raw
<script module lang="ts">
	export interface FeatureCardImage {
		src: string;
		/** Empty string when the image only illustrates the text beside it. */
		alt: string;
		width: number;
		height: number;
	}

	export interface FeatureCardLink {
		label: string;
		href: string;
	}

	export type FeatureCardVariant = 'plain' | 'bordered' | 'filled';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Capability name, set as the card heading. */
		title: string;
		/** One to three sentences on what the capability does. */
		description: string;
		/** Decorative glyph above the title, drawn at 24 px in the ink colour. Ignored when image is set. */
		icon?: Snippet;
		/** Media above the text, cropped to 3:2 with object-cover. */
		image?: FeatureCardImage;
		/** Detail link pinned to the bottom of the card. */
		link?: FeatureCardLink;
		/** Short status label beside the title, e.g. "New". */
		badge?: string;
		headingLevel?: 3 | 4;
		variant?: FeatureCardVariant;
	}

	let {
		title,
		description,
		icon,
		image,
		link,
		badge,
		headingLevel = 3,
		variant = 'bordered'
	}: Props = $props();

	const uid = $props.id();
	const heading = $derived(`h${headingLevel}`);
	const contained = $derived(variant !== 'plain');

	/*
	 * A label such as "Learn more" says nothing in a screen reader's list of links, so the title is
	 * added as visually hidden text unless the label already contains it.
	 */
	const fold = (text: string) => text.toLocaleLowerCase().replace(/\s+/g, ' ').trim();
	/* The title counts only as a whole phrase, so a title of "AI" is not found inside "Details". */
	const containsPhrase = (label: string, phrase: string) => {
		const at = phrase ? label.indexOf(phrase) : -1;
		if (at < 0) return false;
		const letter = /[\p{L}\p{N}]/u;
		return !letter.test(label[at - 1] ?? '') && !letter.test(label[at + phrase.length] ?? '');
	};
	const namesFeature = $derived(link ? containsPhrase(fold(link.label), fold(title)) : true);
</script>

<article
	class={[
		'feature-card @container flex h-full min-w-0 flex-col text-start',
		variant === 'bordered' && 'rounded-2xl bg-[var(--_surface)] p-6 ring-1 ring-[var(--_hairline)]',
		variant === 'filled' && 'rounded-2xl bg-[var(--_fill)] p-6'
	]}
	aria-labelledby="{uid}-title"
>
	{#if image}
		<!-- Inside a card the frame sits 8 px from the edge, so its 8 px corners share the card's centre. -->
		<div
			class={[
				'feature-card__frame relative mb-6 overflow-hidden rounded-lg',
				contained && '-mx-4 -mt-4'
			]}
		>
			<img
				src={image.src}
				alt={image.alt}
				width={image.width}
				height={image.height}
				loading="lazy"
				decoding="async"
				class="aspect-3/2 w-full object-cover"
			/>
		</div>
	{:else if icon}
		<div
			class="mb-4 flex size-6 text-[var(--_ink)] [&>svg]:size-6 [&>svg]:shrink-0"
			aria-hidden="true"
		>
			{@render icon()}
		</div>
	{/if}

	<!-- In a narrow card the badge drops below the title, so the title keeps the full column. -->
	<div class="flex min-w-0 flex-col items-start gap-2 @xs:flex-row @xs:justify-between @xs:gap-3">
		<svelte:element
			this={heading}
			id="{uid}-title"
			class="feature-card__tight min-w-0 text-lg/6 font-semibold tracking-[-0.015em] text-balance break-words text-[var(--_ink)]"
		>
			{title}
		</svelte:element>
		{#if badge}
			<!-- The badge box is one title line tall, so it stays level with the first line. -->
			<p class="flex h-6 shrink-0 items-center">
				<span
					class="rounded-md bg-[var(--_accent)] px-2 text-xs/5 font-medium text-[var(--_on-accent)]"
					>{badge}</span
				>
			</p>
		{/if}
	</div>

	<p
		class="feature-card__prose mt-3 max-w-prose text-base/6 text-pretty break-words text-[var(--_muted)]"
	>
		{description}
	</p>

	{#if link}
		<div class="mt-auto pt-6">
			<a
				href={link.href}
				class="feature-card__link inline-flex max-w-full items-center gap-2 rounded-md text-sm/6 font-medium break-words text-[var(--_accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]"
			>
				<span class="feature-card__label min-w-0"
					>{link.label}{#if !namesFeature}<span class="sr-only">: {title}</span>{/if}</span
				>
				<svg
					class="feature-card__arrow size-4 shrink-0"
					viewBox="0 0 16 16"
					fill="none"
					aria-hidden="true"
				>
					<path
						d="M3 8h9.5M8.5 4l4 4-4 4"
						stroke="currentColor"
						stroke-width="1.75"
						stroke-linecap="round"
						stroke-linejoin="round"
					/>
				</svg>
			</a>
		</div>
	{/if}
</article>

<style>
	/* Public tokens: set --feature-card-* on the card or any ancestor to retone it. */
	.feature-card {
		--_accent: var(--feature-card-accent, #6d28d9);
		--_on-accent: var(--feature-card-on-accent, #ffffff);
		--_ink: var(--feature-card-ink, #18181b);
		--_muted: var(--feature-card-muted, #52525b);
		--_hairline: var(--feature-card-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--feature-card-surface, #ffffff);
		--_fill: var(--feature-card-fill, #f4f4f5);
	}

	/* A hairline drawn over the image, so a pale picture still has an edge. */
	.feature-card__frame::after {
		content: '';
		position: absolute;
		inset: 0;
		border-radius: inherit;
		box-shadow: inset 0 0 0 1px var(--_hairline);
		pointer-events: none;
	}

	/*
	 * The link is 48 px tall for coarse pointers, but its negative block margins keep the visible
	 * gap above and below it at 24 px, the same as the card's padding.
	 */
	.feature-card__link {
		min-height: 48px;
		margin-block: -12px;
		transition:
			color 150ms cubic-bezier(0.2, 0, 0, 1),
			transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	/*
	 * Hover moves the tone towards the ink, which only ever raises contrast, and draws a thin
	 * underline so the change reads on the neutral palette too, where accent and ink are one colour.
	 */
	.feature-card__link:hover {
		color: color-mix(in oklab, var(--_accent) 78%, var(--_ink));
	}
	.feature-card__link:hover .feature-card__label {
		text-decoration-line: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 4px;
	}
	/* Press: one step deeper and one pixel down, for 80 ms. */
	.feature-card__link:active {
		color: color-mix(in oklab, var(--_accent) 60%, var(--_ink));
		transform: translateY(1px);
		transition-duration: 80ms;
	}
	.feature-card__arrow {
		transition: transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	.feature-card__link:hover .feature-card__arrow {
		transform: translateX(2px);
	}
	.feature-card__arrow:dir(rtl) {
		transform: scaleX(-1);
	}
	.feature-card__link:hover .feature-card__arrow:dir(rtl) {
		transform: scaleX(-1) translateX(2px);
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.feature-card__tight:dir(rtl) {
		letter-spacing: 0;
	}
	/* Chinese, Japanese and Korean have no spaces to balance on; keep words whole where possible. */
	.feature-card__tight:is(:lang(zh), :lang(ja), :lang(ko)) {
		letter-spacing: 0;
		word-break: keep-all;
	}
	/*
	 * Japanese breaks at phrase boundaries where the browser can find them, and never starts a
	 * line with a small kana or a long-vowel mark. keep-all is the fallback for older engines.
	 */
	:is(.feature-card__tight, .feature-card__prose):lang(ja) {
		line-break: strict;
		word-break: auto-phrase;
	}

	@media (prefers-reduced-motion: reduce) {
		.feature-card__link:active {
			transform: none;
		}
		.feature-card__link:hover .feature-card__arrow {
			transform: none;
		}
		.feature-card__link:hover .feature-card__arrow:dir(rtl) {
			transform: scaleX(-1);
		}
	}
</style>

Presentational only: renders one card from props and an optional icon snippet. It does not lay out several cards; put it in your own grid (each card stretches to its cell height). No runtime packages beyond Svelte and Tailwind CSS.

Suggested location
src/lib/components/feature-card-01
Required props
titledescription

Limitations

  • Does not arrange several cards; the grid, its columns and gaps are yours.
  • Shows either an image or an icon. When both are passed the image wins and the icon is not rendered.
  • The whole card is not clickable; only the link is. A stretched-link card is a source edit.
  • Images are cropped to 3:2 with object-cover; pick art whose subject survives the crop, or change aspect-3/2 in the source.
  • 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 FeatureCard from '$lib/components/feature-card-01/FeatureCard.svelte';
</script>

<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	<li>
		<FeatureCard
			title="Next-day collection"
			description="Order before 2 pm and collect from the shop from 7 am tomorrow."
			badge="New"
			link={{ label: 'Start an order', href: '/order' }}
		>
			{#snippet icon()}
				<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
					<circle cx="12" cy="12" r="8" />
					<path d="M12 8v4l2.5 2.5" />
				</svg>
			{/snippet}
		</FeatureCard>
	</li>
</ul>

Feature card#

Placing it#

Copy FeatureCard.svelte into src/lib/components/feature-card-01/. The card lays out one capability; the grid around it is yours. Put each card in a list item of any grid:

Svelte
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	{#each features as feature (feature.title)}
		<li><FeatureCard {...feature} /></li>
	{/each}
</ul>

The card is h-full and a flex column, so every card in a row takes the row's height and the links sit on one line at the bottom, however long each description runs.

FeatureCardImage, FeatureCardLink and FeatureCardVariant are exported from the component's module script.

Content order#

Part When it renders
Image image is set. Cropped to 3:2, framed 8 px inside a contained card.
Icon icon is set and image is not. Drawn at 24 px in the ink colour.
Title Always. The heading level comes from headingLevel (3 or 4).
Badge badge is set. Beside the title, level with its first line; below it in a narrow card.
Description Always.
Link link is set. Pinned to the bottom of the card.

With neither an image nor an icon, the card starts at the title; no empty media box renders.

Variants#

  • bordered (default): white surface, one-pixel hairline, 16 px corners.
  • filled: a raised zinc fill with no hairline, for cards on a white page with nothing else bordered nearby.
  • plain: no container or padding. Use it in dense grids that separate items with space or dividers.

A link labelled "Learn more" is meaningless in a screen reader's list of links. When the label does not contain the title, the card appends the title as visually hidden text, so "Learn more" is announced as "Learn more: Board templates". A specific label ("See cake sizes") is still better for everyone.

Retoning#

Seven variables, set on the card or any ancestor, cover every colour it draws:

Variable Default Draws
--feature-card-accent #18181b Link, focus ring, badge fill
--feature-card-on-accent #ffffff Badge text
--feature-card-ink #18181b Title, icon
--feature-card-muted #52525b Description
--feature-card-hairline rgb(0 0 0 / 0.08) Bordered card edge, image frame
--feature-card-surface #ffffff Bordered card background, link hover mix
--feature-card-fill #f4f4f5 Filled card background

A dark page:

Svelte
<div
	style="--feature-card-surface: #18181b; --feature-card-fill: #27272a; --feature-card-ink: #fafafa; --feature-card-muted: #a1a1aa; --feature-card-hairline: rgb(255 255 255 / 0.1); --feature-card-accent: #fafafa; --feature-card-on-accent: #18181b;"
>
	<FeatureCard title="Order by 2 pm, collect tomorrow" description="…" />
</div>

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
titlestringYesNoneCapability name, set as the card heading.
descriptionstringYesNoneOne to three sentences on what the capability does.
iconSnippetNoNoneDecorative inline SVG drawn at 24 px in the ink colour above the title. Hidden from assistive technology. Ignored when image is set.
imageFeatureCardImageNoNone{ src, alt, width, height }: media above the text, cropped to 3:2 with object-cover. Use alt: "" when the image only illustrates the text.
badgestringNoNoneShort status label beside the title, such as "New", set on the accent.
headingLevel3 | 4No3Heading level of the title, to fit the page outline.
variantFeatureCardVariantNo'bordered''plain' | 'bordered' | 'filled'. Plain has no container, bordered a hairline on the surface colour, filled a raised fill.

Customization#

On this page

Change content through props, retone the card through seven --feature-card-* CSS variables, and edit Tailwind classes in the source for padding, radius or the image crop.

  • Accent: --feature-card-accent colours the link, the focus ring and the badge fill; --feature-card-on-accent is the badge text. Keep the pair at 4.5:1.
  • Text: --feature-card-ink sets the title and the icon; --feature-card-muted the description. Keep muted at 4.5:1 against the surface or fill.
  • Surfaces: --feature-card-surface is the bordered card's background, --feature-card-fill the filled card's; --feature-card-hairline draws the bordered edge and the image frame.
  • Dark page retone: surface #18181b, fill #27272a, ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.1), accent #fafafa, on-accent #18181b (all --feature-card-*). usage.md has the snippet.
  • Icons: pass an inline SVG in the icon snippet with stroke="currentColor"; it is sized to 24 px and takes the ink colour, since it labels rather than acts.
  • Links: make the label specific where you can ("See cake sizes"); a generic "Learn more" still gets a unique accessible name from the title.
  • Grid: put cards in any grid; each card is h-full and flex-col, so the link sits at the bottom of the row's tallest cell.
  • Headings: set headingLevel to 4 when the grid sits under an h3.

Public CSS variables

VariableToken
--feature-card-accentaccent
--feature-card-on-accentonAccent
--feature-card-inkink
--feature-card-mutedmuted
--feature-card-hairlinehairline
--feature-card-surfacesurface
--feature-card-fillfill

Accessibility#

On this page
  • Each card is an article labelled by its title heading; set headingLevel so the heading fits the page outline.
  • The icon wrapper is aria-hidden; the icon is decoration beside the title.
  • Image alt text comes from image.alt; pass an empty string when the picture only illustrates the text.
  • When the link label does not contain the title ("Learn more"), the title is appended as visually hidden text, so every link in a grid has a unique accessible name.
  • The link shows a two-pixel accent outline offset by two pixels on :focus-visible only, and is 48 px tall for coarse pointers while its visible gaps stay 24 px.
  • Hover darkens the link one step towards the ink colour (so it never loses contrast), underlines the label and nudges the arrow two pixels; pressing deepens it again and moves it down one pixel for 80 ms. Both movements are removed under prefers-reduced-motion. The card itself has no hover because it is not clickable.
  • Contrast on white: muted #52525b 7.7:1; ink #18181b 17.7:1; badge white on #18181b 17.7:1, on blue #1d4ed8 6.7:1, on violet #6d28d9 7.1:1. On the filled #f4f4f5 muted text measures 7.1:1.
  • Logical properties throughout: the layout, badge and arrow mirror under dir="rtl", and title tracking resets for right-to-left and CJK text; Japanese wraps at phrase boundaries with strict line breaking where the browser supports it.

Known limitations

  • Contrast is computed for the shipped palettes only; re-check any changed token (4.5:1 text, 3:1 focus ring against the surface).
  • The hidden title is skipped only when the label already contains the whole title as a phrase; any other label, however specific, is announced with the title appended.

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.