Card
Card groups related content inside a padded, bordered surface you shape yourself: a freeform box, a numbered or iconed row, an image with a caption, or a two-layer card that reveals more on hover or tap. Turn any of them into a clickable link to somewhere else.
Dependencies
A short summary of what changed this week, sent every Monday.
When to use
Three members, one shared link behavior:
eun-card: a padded, bordered, optionally-shadowed surface around whatever you slot into it, arranged in a simple column with a consistent gap. It has no opinion beyond that surface: no dedicated slots for a title, a leading icon, or a description, and no forced typography on any of them. A numbered step card, an icon-and-text row, a dense list item, a stat tile, a tag list, or any other shape you need is a matter of slotting your own markup, styled with your own CSS, rather than picking a layout variant baked into the component. See Examples below for several of these shapes built this way.- The image card variant: an image with a title/description footer, such as a blog post preview, a product tile, or a team member card. See Alternatives below for a gallery of several images with a lightbox instead of one static image.
- The sliding card variant: a two-layer hover/tap reveal for progressive disclosure that stays visually contained in one card-sized footprint (a stat that reveals its breakdown, a preview that reveals a call to action). See Alternatives below for when a disclosure that pushes the rest of the page down instead fits better.
Independent of what's slotted in, an appearance setting picks the surface treatment: the default look is a plain surface-colored box with a border, while a filled or raised look drops the border instead, for a muted tinted background or a drop shadow respectively. On hover, fill's tint deepens and raised's shadow grows, while a linked card also lifts and its border turns primary.
Link card
Any of the five can become a real link by giving it a destination (and, for the sliding card variant, see the "Front layer is never a link" note in the Accessibility tab, since it applies to the back layer there, not the whole card). Opening it in a new tab adds the appropriate security attributes automatically, a small arrow badge in the top-right corner, and appends "(opens in a new tab)" to the accessible name, the exact same contract as the button component's own link mode, just positioned as an absolute badge instead of an inline icon so it reads naturally against a card's free-form content instead of competing with a title or description for horizontal space.
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/card.js";
import "@eunomia/elements/image-card.js";
import "@eunomia/elements/sliding-card.js";
<eun-card>A bare, padded card.</eun-card>
Importing a file registers its custom element, with no further setup needed. It works with any framework, or none, since it's a standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/card.js";
</script>
<eun-card>A bare, padded card.</eun-card>
npm install @eunomia/elements
import "@eunomia/elements/card.js";
function Info() {
return <eun-card>A bare, padded card.</eun-card>;
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/card.js";
export function Info() {
return <eun-card>A bare, padded card.</eun-card>;
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/card.js";
</script>
<template>
<eun-card>A bare, padded card.</eun-card>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/card.js";
@Component({
selector: "app-info",
template: `<eun-card>A bare, padded card.</eun-card>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class InfoComponent {}
Alternatives
| You want… | Prefers |
|---|---|
| Progressive disclosure that pushes the rest of the page down instead of staying contained | eun-collapse |
| A gallery of multiple images with a lightbox | eun-image-carousel |
Guidance
- Reach for
eun-image-cardwhen the shape you need is an image with a caption, andeun-sliding-cardfor a hover/tap reveal. Compose everything else with a plaineun-cardand your own markup (see When to use and Examples above) - Slot a heading element (
h2–h6) for any title-like content, matching the surrounding page's own outline, sinceeun-cardenforces no heading level of its own (see the Accessibility tab) - Set
aria-hidden="true"yourself on genuinely decorative leading content (a number, an icon), and leave it announced when it carries unique information like a step number (see the Accessibility tab) - Reserve
target="_blank"for links that genuinely open elsewhere, since it changes the accessible name and adds the external-link badge automatically
- Trying to make the sliding card's front layer a link.
href/targetonly ever apply to the back layer, on purpose (see the Accessibility tab) - Overriding colors and spacing with inline styles instead of the
--card-*CSS variables - Reintroducing grid/row/cell ARIA roles around a composed row layout. A card's content is purely visual, not an interactive data-grid widget (see the Accessibility tab)
Live testing
Properties
Card <eun-card>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | An accessible label that overrides the card content. When the card also opens in a new tab, a note announcing it is appended automatically. Ignored with a slotted anchor, which takes it instead |
| appearance | 'default' | 'fill' | 'raised' | 'default' | The card's surface style. Default is a plain surface colored box with a border. Fill and raised both drop the border, fill for a muted tinted background and raised for a drop shadow. On hover or focus, fill deepens its tint and raised grows its shadow, link or not, while the primary border and the lift only apply to a linked card |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| href | string | — | The URL the card navigates to. When set, the card renders as a link instead of a plain wrapper |
| target | '_blank' | '_self' | '_parent' | '_top' | — | Where to open the linked URL. Only used when href is set. Opening in a new tab automatically adds an external link badge and announces it to assistive technology, in the current locale |
| locale | string | — | The locale used to resolve the "opens in a new tab" announcement. Defaults to the one set with setDefaultLocale, English otherwise |
| download | string | — | The filename used when the linked URL is downloaded instead of navigated to. Only used when href is set |
| isExternal | — | Whether the link, if any, opens in a new tab. | |
| linkAriaLabel | — | The accessible label to apply to the rendered `<a>` : the author's own `aria-label` with "(opens in a new tab)" appended when external, unchanged otherwise. `undefined` when no `aria-label` was set at all — in that case renderNewTabHint's visually-hidden span carries the same information instead, so it isn't announced twice. | |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| (default) | The card's content, arranged in a column by `--card-gap`. A native anchor as a direct child links the whole card when href is unset |
Events
| Name | Type | Description |
|---|---|---|
| eunnavigate | NavigationEvent | Fired before following href, so a client-side router can intercept the navigation. Not fired for downloads, and cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --card-padding | Sets the padding of the card |
| --card-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --card-padding is set |
| --card-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --card-padding is set |
| --card-gap | Sets the gap between the card's own direct children |
| --card-border-radius | Sets the corner radius of the card |
| --card-border-color | Sets the border color, for every appearance. Fill and raised have no border by default, so it draws one when set |
| --card-border-width | Sets the border width, for every appearance. Fill and raised have a transparent border by default, so it only shows once --card-border-color is set |
| --card-background | Sets the background color, for every appearance. The fill appearance reads card-fill-background first, then this one |
| --card-fill-background | Sets the background color for the fill appearance |
| --card-fill-hover-background | Sets the background color on hover for the fill appearance |
| --card-shadow | Sets a box shadow for the default appearance, none by default. Replaced by a subtle elevation for the raised appearance, and unused by fill |
| --card-raised-hover-shadow | Sets the box shadow on hover for the raised appearance. Ignored when --card-hover-shadow is set |
| --card-hover-shadow | Sets the box shadow on hover or focus, for every appearance, linked or not. Defaults to the resting shadow, or a deeper one for raised |
| --card-overflow | Sets the overflow of the card, visible by default. Set it to hidden to clip a decoration or a full-bleed image to the rounded corners |
| --card-hover-transform | Sets a transform applied on hover or focus, for every appearance. Setting this always applies it, link or not. Left unset, a small lift only applies to linked cards, since a non-linked card has nothing to click |
| --card-hover-border-color | Sets the border color on hover or focus, for every appearance, link or not. Fill and raised keep a transparent border at rest, so the color reveals with no layout shift. Defaults to the primary color for a linked card, otherwise the border stays as it is at rest, since there's nothing to click |
| --card-hover-background | Sets the background color on hover or focus, for the default appearance only, unchanged by default. See card-fill-hover-background for the fill appearance |
| --card-hover-duration | Sets the duration of the hover transition |
| --card-transition | Overrides the card's whole hover transition, for example to animate an extra property alongside the built-in ones. Defaults to transform, outline, box shadow, background color, and border color |
| --card-focus-outline-color | Sets the focus outline color, for linked cards only, since a non-linked card has nothing native to receive keyboard focus |
| --card-link-badge-offset | Sets the inset of the external link badge from the card's top and right edges |
| --card-link-badge-size | Sets the diameter of the external link badge |
| --card-link-badge-background | Sets the background color of the external link badge |
| --card-link-badge-color | Sets the icon color of the external link badge |
CSS shadow parts
| Name | Description |
|---|---|
| card | The outer box, a link once href is set, a plain container otherwise. Exposed for effects the card's CSS custom properties don't cover, such as a hover accent. It isolates its own stacking context, so a decoration placed at z-index -1 stays above its background and below its content |
Image card <eun-image-card>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | An accessible label that overrides the card content. When the card also opens in a new tab, a note announcing it is appended automatically. Ignored with a slotted anchor, which takes it instead |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| href | string | — | The URL the card navigates to. When set, the card renders as a link instead of a plain wrapper |
| target | '_blank' | '_self' | '_parent' | '_top' | — | Where to open the linked URL. Only used when href is set. Opening in a new tab automatically adds an external link badge and announces it to assistive technology |
| locale | string | — | The locale used to resolve the "opens in a new tab" announcement. Defaults to the one set with setDefaultLocale, English otherwise |
| download | string | — | The filename used when the linked URL is downloaded instead of navigated to. Only used when href is set |
| isExternal | — | Whether the link, if any, opens in a new tab. | |
| linkAriaLabel | — | The accessible label to apply to the rendered `<a>` : the author's own `aria-label` with "(opens in a new tab)" appended when external, unchanged otherwise. `undefined` when no `aria-label` was set at all — in that case renderNewTabHint's visually-hidden span carries the same information instead, so it isn't announced twice. | |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| image | The card's image, as a plain image element shown edge to edge, ignoring padding. The alt text is the author's own responsibility: describe the image, or leave it empty when it's purely decorative and the title or description already convey the same information |
| title | The card's title, below the image. Use a heading element at whatever level fits the surrounding page structure. A native anchor slotted here links the whole card when href is unset. The title typography is inherited, so a composition styles its own parts freely |
| description | The card's description, below the title. Its typography is inherited too |
Events
| Name | Type | Description |
|---|---|---|
| eunnavigate | NavigationEvent | Fired before following href, so a client-side router can intercept the navigation. Not fired for downloads, and cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --card-padding | Sets the padding of the footer, holding the title and description |
| --card-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --card-padding is set |
| --card-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --card-padding is set |
| --card-gap | Sets the gap between the title and description |
| --card-border-radius | Sets the corner radius of the card |
| --card-border-color | Sets the border color of the card |
| --card-border-width | Sets the width of the border |
| --card-background | Sets the background color of the card |
| --card-shadow | Sets a box shadow, none by default |
| --card-hover-shadow | Sets the box shadow on hover or focus, linked or not. Defaults to the resting shadow |
| --card-overflow | Sets the overflow of the card, hidden by default so the image follows the rounded corners |
| --card-hover-transform | Sets a transform applied on hover or focus, for linked cards only, defaulting to a small lift |
| --card-hover-border-color | Sets the border color on hover or focus. Defaults to the primary color for a linked card, otherwise the border stays as it is at rest, since there's nothing to click |
| --card-hover-background | Sets the background color on hover or focus, linked or not, unchanged by default |
| --card-hover-duration | Sets the duration of the hover transition |
| --card-transition | Overrides the card's whole hover transition, for example to animate an extra property alongside the built-in ones. Defaults to transform, box shadow, background color, and border color |
| --card-focus-outline-color | Sets the focus outline color, for linked cards only |
| --image-card-image-height | Sets the height of the image, 180px by default. Set it to auto to size the image from its aspect ratio instead |
| --image-card-image-aspect-ratio | Sets the aspect ratio of the image, such as 4 / 3. Only applies once --image-card-image-height is auto |
| --image-card-title-size | Sets the font size of the title |
| --image-card-title-weight | Sets the font weight of the title |
| --image-card-title-color | Sets the color of the title |
| --image-card-description-size | Sets the font size of the description |
| --image-card-description-weight | Sets the font weight of the description |
| --image-card-description-color | Sets the color of the description |
| --card-link-badge-offset | Sets the inset of the external link badge from the card's top and right edges |
| --card-link-badge-size | Sets the diameter of the external link badge |
| --card-link-badge-background | Sets the background color of the external link badge |
| --card-link-badge-color | Sets the icon color of the external link badge |
CSS shadow parts
| Name | Description |
|---|---|
| card | The outer box, a link once href is set, a plain container otherwise. Exposed for effects the card's CSS custom properties don't cover, such as a hover accent |
| footer | The box holding the title and description, below the image |
Sliding card <eun-sliding-card>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | The accessible label for the front layer's toggle control, touch devices only, since desktop has no such control. When the back layer also opens in a new tab, a note announcing it is appended automatically |
| hint | boolean | false | Loops a bounce animation on touch devices while closed, hinting that the card is interactive |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| href | string | — | The URL the back layer navigates to. When set, the back layer renders as a link instead of a plain container |
| target | '_blank' | '_self' | '_parent' | '_top' | — | Where to open the linked URL. Only used when href is set. Opening in a new tab automatically adds an external link badge and announces it to assistive technology |
| locale | string | — | The locale used to resolve the "opens in a new tab" announcement. Defaults to the one set with setDefaultLocale, English otherwise |
| download | string | — | The filename used when the linked URL is downloaded instead of navigated to. Only used when href is set |
| isExternal | — | Whether the link, if any, opens in a new tab. | |
| linkAriaLabel | — | The accessible label to apply to the rendered `<a>` : the author's own `aria-label` with "(opens in a new tab)" appended when external, unchanged otherwise. `undefined` when no `aria-label` was set at all — in that case renderNewTabHint's visually-hidden span carries the same information instead, so it isn't announced twice. | |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| (default) | The front layer's content, always visible at rest. Never make this a link |
| back | The back layer's content, revealed by the slide. Avoid nesting further interactive elements inside it once href is also set on the card itself, since the whole layer already becomes a link |
Events
| Name | Type | Description |
|---|---|---|
| eunnavigate | NavigationEvent | Fired before following href, so a client-side router can intercept the navigation. Not fired for downloads, and cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --sliding-card-width | Sets the width of the card |
| --sliding-card-height | Sets the height of the card |
| --sliding-card-slide-offset | Sets how far the front layer slides to reveal the back |
| --sliding-card-duration | Sets the duration of the slide and hint animations |
| --sliding-card-border-radius | Sets the corner radius of both layers |
| --sliding-card-back-inset | Sets how far the back layer sits inside the front layer's own edge, on the top/left/right sides. Keeps the back layer fully hidden underneath the front layer at rest, rather than sized to match it exactly, so the front layer's rounded corners never cut through to whatever's behind the card on the page |
| --sliding-card-padding | Sets the padding of both layers |
| --sliding-card-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --sliding-card-padding is set |
| --sliding-card-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --sliding-card-padding is set |
| --sliding-card-front-background | Sets the background color of the front layer, and (since the host itself carries no visible box beyond what the front layer already covers) the host's own background showing through the front layer's rounded corners |
| --sliding-card-back-background | Sets the background color of the back layer |
| --sliding-card-back-color | Sets the text color of the back layer |
| --sliding-card-fade-color | Sets the color of the bottom fade, which fades to transparent. Match your page's own background for it to blend in |
| --sliding-card-fade-height | Sets the height of the bottom fade, and the boundary past which the slid front layer is never rendered |
| --sliding-card-focus-outline-color | Sets the focus outline color of the front layer's toggle control, touch devices only |
| --card-link-badge-offset | Sets the inset of the external link badge from the back layer's top and right edges |
| --card-link-badge-size | Sets the diameter of the external link badge |
| --card-link-badge-background | Sets the background color of the external link badge |
| --card-link-badge-color | Sets the icon color of the external link badge |
CSS shadow parts
| Name | Description |
|---|---|
| back | The back layer's own outer box, a link once href is set, a plain container otherwise. Exposed for effects the sliding card's CSS custom properties don't cover, such as an accent |
Default
Padding, a border and a radius come built in. See Appearance below for
the two border-free alternatives. The hover feedback, a border that
turns primary and a small lift, only shows up once the card is a link.
A plain, non-linked card stays as it is on hover, since nothing
indicates it'd actually go anywhere, unless --card-hover-border-color
or --card-hover-transform is set explicitly. A
custom shadow at rest is the one other thing left opt-in, through
--card-* custom properties. The default slot takes whatever you give it:
plain text, or a structured title/description pair laid out with
--card-gap between them, the same as any other element on the page.
A short summary of what changed this week, sent every Monday.
<eun-card>
<h3>Weekly digest</h3>
<p>A short summary of what changed this week, sent every Monday.</p>
</eun-card>
<eun-card>
A bare card, holding whatever content you slot into it — text, a list, another
component entirely.
</eun-card>
Appearance
appearance picks the card's surface treatment, independent of whatever
is slotted inside it: default (the default) is a plain surface-colored
box with a border.
fill and raised both keep that same border transparent instead of
dropping it outright, fill for a muted tinted background, raised for
a drop shadow. On hover or focus, fill's tint deepens and raised's
shadow grows, and a linked card's border turns primary whatever its
appearance. Keeping a transparent border rather than none at all on fill
and raised means that hover color reveals with no layout shift, since
the space for it was already reserved. The accompanying lift follows
the same rule: it only shows up for a linked card, or once the
--card-hover-transform custom property is set explicitly (see the API
tab). None of the three cards below is a link, so none of them moves or
turns primary on hover.
Bordered. Hover to see the border turn primary.
A muted tinted background, transparent border. Hover to see the background deepen and the border turn primary.
A drop shadow, transparent border. Hover to see the shadow grow and the border turn primary.
<eun-card>Default</eun-card>
<eun-card appearance="fill">Fill</eun-card>
<eun-card appearance="raised">Raised</eun-card>
Custom hover effects
The --card-* custom properties cover common cases (a lift, a tint, a shadow),
but for anything beyond that, a growing top accent, for instance, the outer box is exposed as the card shadow part,
so a consumer can attach their own ::before/::after from outside without any change to the component itself.
position: relative is already set on the part, so the ::before sizes itself against the card's own box regardless of appearance or what's slotted in.
Hover the card below to see it in action.
A top accent grown entirely from outside the component.
<eun-card class="hover-accent">Hover me</eun-card>
.hover-accent-demo {
--card-hover-border-color: var(--eun-border-color);
}
.hover-accent::part(card) {
overflow: hidden;
}
.hover-accent::part(card)::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
transform: scaleX(0);
transform-origin: left;
background: royalblue;
transition: transform 0.3s ease;
}
.hover-accent::part(card):hover::before {
transform: scaleX(1);
}
Part of the
Background decorations
A watermark, a fill rising on hover or a full-bleed image goes into the
card as a regular child, positioned absolutely. The card is
position: relative and isolates its own stacking context, so a
decoration at z-index: -1 sits above the card's background and below
its content. Set --card-overflow: hidden to clip it to the rounded
corners, and --card-hover-shadow to change the shadow on hover, for
every appearance.
The watermark sits behind the text, clipped by the corners.
<eun-card class="speciality">
<span class="speciality-number" aria-hidden="true">01</span>
<h3>Plumbing</h3>
<p>The watermark sits behind the text, clipped by the corners.</p>
</eun-card>
.speciality {
--card-overflow: hidden;
--card-hover-shadow: var(--eun-shadow-l);
}
.speciality-number {
position: absolute;
right: -8px;
bottom: -24px;
z-index: -1;
font-size: 96px;
color: var(--eun-color-primary-alpha-10);
}
--card-overflow: hidden also clips the focus ring of a slotted anchor, drawn just outside the card. Leave it visible on a card linked that way.
Image card
An image on top, edge-to-edge, and a title/description footer below it.
A look at how Eunomia keeps every surface consistent.
<eun-image-card>
<img
slot="image"
src="/images/eunomia-icon.svg"
alt="Eunomia"
style="object-fit: contain; padding: 32px;"
/>
<span slot="title">Building design systems that scale</span>
<p slot="description">
A look at how Eunomia keeps every surface consistent.
</p>
</eun-image-card>
Linking with a slotted anchor
Without href, an anchor slotted into title links the whole card. It
lives in the light DOM, so it's part of the server-rendered HTML and can
be your framework's own link component. The anchor is stretched over the
card, which takes the same lift, border and focus ring as with href. A
heading can sit inside it, and when it opens in a new tab the card shows
the arrow badge and announces it.
import Link from "next/link";
<eun-image-card>
<img slot="image" src="/products/mixer.jpg" alt="" />
<Link slot="title" href="/products/mixer">
<h3>Kitchen mixer</h3>
</Link>
<p slot="description">From 129 €</p>
</eun-image-card>;
A linked card is a single navigation target, so keep actions such as an
"add to cart" button out of it: the stretched anchor covers them, and a
control nested in a link is ambiguous for pointer and screen reader
users alike. If one has to stay, lift it above the anchor with
position: relative; z-index: 2.
Layout and typography
The footer stretches to the card's height: in a grid row, give the last
element of the description margin-top: auto to align it at the bottom
across cards. With neither title nor description, the footer isn't
rendered, so an image-only card, such as a logo, has no empty band.
<eun-image-card>
<img
slot="image"
src="/images/eunomia-icon.svg"
alt="Eunomia"
style="object-fit: contain; padding: 24px;"
/>
</eun-image-card>
The image is 180px high by default. Set --image-card-image-height: auto
and --image-card-image-aspect-ratio to size it from a ratio instead.
The title and description typography is inherited rather than forced on
the slotted element, so a composition slotted into either one styles its
own parts with plain CSS. Tune the defaults with
--image-card-title-size, -weight and -color, and their
--image-card-description-* counterparts.
.product-card {
--image-card-image-height: auto;
--image-card-image-aspect-ratio: 4 / 3;
--image-card-description-color: var(--eun-text-color);
}
Sliding card
Hover (or focus something inside the back layer) to reveal the back. It smoothly slides down, fading into the page background at its lower edge rather than cutting off sharply. On a touch device (try this on a phone, or narrow the viewport with the toolbar above and switch to a touch emulator), hovering isn't available, so a tap on the front layer opens it instead. Tap again, tab away, press Escape, or tap outside the card to close it.
Subscriptions: $9,200
One-time sales: $3,280
<eun-sliding-card>
<div class="stat">
<eun-icon name="monitoring" size="32"></eun-icon>
<strong>Monthly revenue</strong>
<span class="amount">$12,480</span>
</div>
<div slot="back">
<strong>Breakdown</strong>
<p>Subscriptions: $9,200<br />One-time sales: $3,280</p>
</div>
</eun-sliding-card>
Touch hint
hint loops a small bounce animation on touch devices, while the card is
still closed, since there's no hover affordance to discover it otherwise.
No effect on desktop, where the hover reveal is already self-evident.
The bounce only loops on touch devices.
<eun-sliding-card hint>
<div class="teaser">
<eun-icon name="touch_app" size="32"></eun-icon>
<strong>Tap to reveal</strong>
</div>
<div slot="back">
<strong>There it is</strong>
<p>The bounce only loops on touch devices.</p>
</div>
</eun-sliding-card>
Composing your own layout
eun-card is a surface, not a template: it contributes the padding,
border, background, and hover/focus treatment, and lays out its direct
children in a column spaced by --card-gap. Everything past that, a
leading icon, a title, tags, a list, a button, is your own markup styled
with your own CSS, combined however a real card actually needs it. The
three examples below reuse the tokens the rest of the library already
exposes (--eun-space-*, --eun-font-size-*, --eun-font-weight-*,
--eun-color-*) and real components (eun-icon, eun-tag,
eun-button), so they stay visually consistent with everything else
without the card component needing a dedicated slot or cssprop for any
of them.
A leading icon or number, with a title and description
The eun-grid--lead-left
grid utilityrow-gap to match a title sitting
right above its description.
Sign up with your email, it takes less than a minute.
We sent a confirmation link, check your inbox.
<eun-card>
<div class="row eun-grid eun-grid--lead-left">
<span class="lead">01</span>
<span class="title">Create an account</span>
<p class="description">
Sign up with your email, it takes less than a minute.
</p>
</div>
</eun-card>
.row {
row-gap: var(--eun-space-xxs, 2px);
}
.lead {
font-size: var(--eun-font-size-l, 28px);
font-weight: var(--eun-font-weight-l, 700);
color: var(--eun-color-primary-500);
}
.title {
font-weight: var(--eun-font-weight-l, 700);
}
.description {
margin: 0;
color: var(--eun-text-secondary);
font-size: var(--eun-font-size-s, 14px);
}
For a denser version of this same row (a sidebar list item, a
notification), there's no separate mode to reach for: tighten the
card's own --card-padding/--card-gap directly, the exact same
eun-card and grid utility, just given less room.
A team member card
A fuller composition: the leading-icon row above, now holding an
avatar and a role, a row of skill tags underneath, and a couple of
icon-only eun-buttons for contact actions, wrapped in a plain flex
row of their own.
Product Designer
<eun-card>
<div class="row eun-grid eun-grid--lead-left">
<span class="avatar">AM</span>
<span class="title">Alice Martin</span>
<p class="description">Product Designer</p>
</div>
<div class="tags">
<eun-tag size="s" rounded>Design systems</eun-tag>
<eun-tag size="s" rounded>Figma</eun-tag>
</div>
<div class="actions">
<eun-button
appearance="ghost"
size="xs"
icon="mail"
aria-label="Email Alice"
></eun-button>
<eun-button
appearance="ghost"
size="xs"
icon="link"
aria-label="Alice's website"
></eun-button>
</div>
</eun-card>
.avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--eun-color-primary-100);
color: var(--eun-color-primary-500);
font-weight: var(--eun-font-weight-l, 700);
}
.tags,
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--eun-space-xs, 4px);
}
A pricing plan card
Everything a real plan card needs, an eyebrow label, a price, a
description, a feature checklist, and a full-width call to action, all
just slotted content styled to taste. appearance="raised" lifts it
off the page a little, fitting for a card meant to be compared side by
side with others.
Everything in Free, plus priority support and custom domains.
Unlimited projects Priority support Custom domains
<eun-card appearance="raised">
<span class="eyebrow">Pro plan</span>
<div class="price">
<span class="price-amount">$29</span>
<span class="price-period">/month</span>
</div>
<p class="description">
Everything in Free, plus priority support and custom domains.
</p>
<ul class="checklist">
<li>
<eun-icon name="check_circle" size="18"></eun-icon> Unlimited projects
</li>
<li>
<eun-icon name="check_circle" size="18"></eun-icon> Priority support
</li>
<li><eun-icon name="check_circle" size="18"></eun-icon> Custom domains</li>
</ul>
<eun-button style="width: 100%; justify-content: center;">Upgrade</eun-button>
</eun-card>
.eyebrow {
color: var(--eun-color-primary-500);
font-size: var(--eun-font-size-s, 14px);
font-weight: var(--eun-font-weight-l, 700);
}
.price {
display: flex;
align-items: baseline;
gap: var(--eun-space-xxs, 2px);
}
.price-amount {
font-size: var(--eun-font-size-xl, 28px);
font-weight: var(--eun-font-weight-l, 700);
}
.price-period {
color: var(--eun-text-secondary);
font-size: var(--eun-font-size-s, 14px);
}
.checklist {
display: flex;
flex-direction: column;
gap: var(--eun-space-xs, 4px);
margin: 0;
padding: 0;
list-style: none;
font-size: var(--eun-font-size-s, 14px);
}
.checklist li {
display: flex;
align-items: center;
gap: var(--eun-space-xs, 4px);
}
Link states
Every card in this family shares the same href/target mechanic:
eun-card, eun-image-card, and (for its back layer only)
eun-sliding-card.
Basic
Set href to turn the whole card into a native link. target="_blank"
adds the external-link badge (top-right) and appends "(opens in a new
tab)" to the accessible name. Works identically on eun-card and
eun-image-card.
Opens on this same page.
Opens in a new tab.
Opens in a new tab.
<eun-card href="https://example.com">
<div class="row eun-grid eun-grid--lead-left">
<eun-icon name="description" size="28" class="lead"></eun-icon>
<span class="title">Read the changelog</span>
<p class="description">Opens on this same page.</p>
</div>
</eun-card>
<eun-card href="https://example.com" target="_blank">
<div class="row eun-grid eun-grid--lead-left">
<eun-icon name="open_in_new" size="28" class="lead"></eun-icon>
<span class="title">View source</span>
<p class="description">Opens in a new tab.</p>
</div>
</eun-card>
<eun-image-card href="https://example.com" target="_blank">
<img slot="image" src="/images/eunomia-icon.svg" alt="Eunomia" />
<span slot="title">Read the full article</span>
<p slot="description">Opens in a new tab.</p>
</eun-image-card>
Sliding card
href/target apply to the back layer only, never the front, which
is the reveal trigger, not a destination (see the Accessibility tab).
Opens in a new tab.
<eun-sliding-card href="https://example.com" target="_blank">
<div class="teaser">
<eun-icon name="description" size="32"></eun-icon>
<strong>Case study</strong>
</div>
<div slot="back">
<strong>Read the full case study</strong>
<p>Opens in a new tab.</p>
</div>
</eun-sliding-card>
SPA navigation
A linked card (eun-card, eun-image-card, or the back layer of
eun-sliding-card) dispatches the same cancelable eunnavigate event
as href, letting an SPA router intercept it for client-side
routing instead of a full page load. It's never dispatched for
download links, since a file download isn't a navigation. See the
Search engines and server rendering
With href, the card's <a> only exists in the shadow root, so a
crawler that doesn't run JavaScript finds no link in the server HTML.
For a card leading to one of your own pages, leave href unset on
eun-card and place a native anchor as a direct child instead, typically
wrapping the title. The anchor is stretched over the whole card, which
takes the same hover lift and focus ring as a card with href, while
its accessible name stays the anchor's own text. The anchor is also what
takes focus, so an aria-label goes on it. One left on eun-card is
ignored in this mode, and a console warning names it.
Keep links visible to crawlers.
<eun-card>
<a href="/guides/server-rendering/"><strong>Server-side rendering</strong></a>
<p>Keep links visible to crawlers.</p>
</eun-card>
import Link from "next/link";
<eun-card>
<Link href="/guides/server-rendering/">
<strong>Server-side rendering</strong>
</Link>
<p>Keep links visible to crawlers.</p>
</eun-card>;
The anchor owns navigation, so no eunnavigate is fired for it. Keep
other interactive content (buttons, other links) out of such a card,
since the stretched anchor covers it. eun-image-card supports the
same mode with an anchor slotted into title (see Image card above).
eun-sliding-card keeps using href.
See the
When the anchor opens in a new tab, the card shows the same arrow badge
as with href, and announces the new tab through the anchor's
aria-description, in the card's locale. The anchor's text and
aria-label stay as you wrote them, and an aria-description of your
own is kept as is.
Recommendations for web components.
<eun-card>
<a href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
<strong>open-wc</strong>
</a>
<p>Recommendations for web components.</p>
</eun-card>
import Link from "next/link";
<eun-card>
<Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
<strong>open-wc</strong>
</Link>
<p>Recommendations for web components.</p>
</eun-card>;
The aria-description is only a fallback: it's added once the element
upgrades, and some screen readers only read a description after a pause,
or leave it out of their links list. To announce the new tab everywhere,
pages without JavaScript included, write the wording inside the anchor
with the eun-new-tab-hint class. It becomes part of the anchor's
accessible name and of the server-rendered HTML, links-ssr.css hides
it visually, and the card then adds no aria-description, so the new
tab is never announced twice.
import Link from "next/link";
<eun-card>
<Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
<strong>open-wc</strong>
<span className="eun-new-tab-hint"> (opens in a new tab)</span>
</Link>
</eun-card>;
The class is hidden by links-ssr.css, so load that stylesheet, as described in the
Server-side rendering
The card's surface lives in its shadow root, so a server-rendered card is
an inline element with no padding, border or background until it
upgrades. Link card-ssr.css in the page's <head> to draw the
surface from the first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/card-ssr.css"
/>
A bundler that resolves package exports can reach it as
@eunomia/elements/card-ssr.css. It covers eun-card,
eun-image-card and eun-sliding-card with their custom properties:
every appearance, the image card's image and footer, and the sliding
card's front layer at rest. Hover and focus styles, and the sliding
card's back layer, arrive with the upgrade.
Custom colors
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--card-border-radius |
Sets the corner radius of the card |
--card-border-color |
Sets the border color, for every appearance. Fill and raised have no border by default, so it draws one when set |
--card-border-width |
Sets the border width, for every appearance. Fill and raised have a transparent border by default, so it only shows once --card-border-color is set |
--card-background |
Sets the background color, for every appearance. The fill appearance reads card-fill-background first, then this one |
--card-fill-background |
Sets the background color for the fill appearance |
--card-fill-hover-background |
Sets the background color on hover for the fill appearance |
--card-shadow |
Sets a box shadow for the default appearance, none by default. Replaced by a subtle elevation for the raised appearance, and unused by fill |
--card-raised-hover-shadow |
Sets the box shadow on hover for the raised appearance |
--card-hover-border-color |
Sets the border color on hover or focus, for every appearance, link or not. Fill and raised keep a transparent border at rest, so the color reveals with no layout shift. Defaults to the primary color for a linked card, otherwise the border stays as it is at rest, since there's nothing to click |
--card-hover-background |
Sets the background color on hover or focus, for the default appearance only, unchanged by default. See card-fill-hover-background for the fill appearance |
--card-focus-outline-color |
Sets the focus outline color, for linked cards only, since a non-linked card has nothing native to receive keyboard focus |
--card-link-badge-background |
Sets the background color of the external link badge |
--card-link-badge-color |
Sets the icon color of the external link badge |
--sliding-card-border-radius |
Sets the corner radius of both layers |
--sliding-card-front-background |
Sets the background color of the front layer, and (since the host itself carries no visible box beyond what the front layer already covers) the host's own background showing through the front layer's rounded corners |
--sliding-card-back-background |
Sets the background color of the back layer |
--sliding-card-back-color |
Sets the text color of the back layer |
--sliding-card-fade-color |
Sets the color of the bottom fade, which fades to transparent. Match your page's own background for it to blend in |
--sliding-card-focus-outline-color |
Sets the focus outline color of the front layer's toggle control, touch devices only |
--card-hover-shadow |
Sets the box shadow on hover or focus, for every appearance, linked or not. Defaults to the resting shadow, or a deeper one for raised |
--image-card-title-color |
Sets the color of the title |
--image-card-description-color |
Sets the color of the description |
Interaction
Keyboard interactions
| Key | Action |
|---|---|
Tab |
Moves focus to a linked card (href set), or into its content |
Enter |
Activates a linked card, or the sliding card's front toggle (touch devices only, see below) |
Space |
Activates the sliding card's front toggle (touch devices only) |
Escape |
Closes an open sliding card, on touch devices |
A card with no href and no interactive slotted content (the common case
for eun-card used as a plain display surface) is not itself focusable.
There's nothing to activate, so it's correctly left out of the tab order
entirely, the same way a plain <div> would be.
Touch vs. desktop interaction model
eun-sliding-card detects hover capability via
matchMedia("(hover: none)"), not a fixed viewport breakpoint, so a
touch-capable laptop with an attached mouse still gets the hover reveal
the moment a pointing device is actually in use. On genuinely touch-only
devices, the front layer becomes a real toggle control (see "Aria
attributes" above). Enter/Space activates it exactly like a tap
would, and it closes on Escape, on a tap/click anywhere outside the
card, or when focus leaves it entirely (Tab away). The first two share
the same stacking mechanism eun-modal/eun-drawer use, so a
sliding-card left open behind a later-opened dialog never intercepts an
Escape/outside click meant for that dialog instead.
Semantics and screen readers
Aria attributes
- Whatever CSS you use to arrange slotted content into a row or a grid
(see Examples above), no
role="grid"/"row"/"cell"belongs on it: that's a purely visual layout, not an interactive data-grid widget, so none of the ARIA grid semantics apply. Adding them would be actively wrong here, since a grid role promises row/column navigation keys and agridcellper cell, neither of which a composed card layout provides. - A leading icon or number you slot in is never automatically
aria-hidden, since only you know whether it's purely decorative or the only place a value like a step number lives. Addaria-hidden="true"yourself on genuinely decorative content. Leave it alone (or add your ownaria-label) when it conveys something unique. eun-cardenforces no heading level of its own. Slot in whatever heading (h2–h6) fits the surrounding page's own outline, the same wayeun-image-card'stitleslot does. A grid of cards inside a page section already headed by an<h2>typically wants<h3>for each card's own title.eun-cardhas no role of its own, since a card is as often a layout box as a piece of content. When a card is a self-contained item, such as a post, a review or a product, give itrole="article".- For a linked card opening in a new tab, "(opens in a new tab)" is
appended to the accessible name automatically in the card's
locale, no manual wording needed, matchingeun-button/eun-link. With a slotted anchor, it goes on the anchor'saria-description. eun-sliding-card's front layer getsrole="button"/aria-expanded/tabindex="0"only on touch devices. On desktop, revealing the back layer is a hover affordance, not a real disclosure control, so none of those apply there. Adding them unconditionally would announce a toggle that doesn't actually exist for a mouse/trackpad user (hovering needs no activation).
Front layer is never a link, on purpose
On eun-sliding-card, href/target apply to the back layer
(slot="back") only. The front layer can never become a link, even
though it's the part always visible at rest. Making the front a link
would defeat the whole reveal, since a click there would navigate away
immediately, before the back layer (and whatever action lives on it) is
ever shown. If your card needs its own destination, put that link's
content in the back layer and reserve the front purely as the "there's
more here" trigger.
Reduced motion
Every animation and transition in this family (the slide reveal, the
open/close animation, the hint bounce, a card's own hover lift) is
disabled under prefers-reduced-motion: reduce. State still changes: the
back layer still becomes visible, and a card still responds to
hover/focus. Only the motion used to get there is removed.
Reference links