Skip to content

HyCard ​

Usage examples on this page are written for Lit / plain HTML (<hy-card>). The same component ships as HyCard in @whitespaceux/harmony-react (native React 19), with typed wrappers for Angular, Solid, Svelte, and Vue. The API reference below applies to all frameworks.

Child components: <hy-card-media> — used only inside this one.

A box for one unit of content — a product, an article, a record, a tile on a dashboard. The card owns its chrome (fill, edge, corner, shadow, inner padding), its behavior (a link, a clickable card, the current card, a disabled card) and the ink of what sits in it; the content is the page's own, composed in the card's slots and styled from the Pages layer. The card sets no text style of its own: a title inside it is the page's heading, in the page's class, at the top of the content — the card has no header part, since a title row needs nothing of the card's geometry (a flex row in the page's own class places an action beside it); the footer stays because it needs the card's height.

Media to the edge ​

A picture in the content goes in hy-card-media, a direct child of the card, anywhere: first, between other content, or last. The card pulls it out through its own padding to the side edges, to the top edge when it is first and to the bottom edge when it is last with no footer, and clips it to its corners — so the picture is rounded where it meets the card's edge and square against the content around it. The text keeps the card's padding. (::slotted() reaches direct children only: media inside a wrapper gets no bleed, and hy-card-media warns in development.) A flush card has no padding, so its content runs to the edge without it.

The choice card ​

A card you pick — a plan, a shipping method, a template — is the item of a radio group or a checkbox group: give it a value and put it in hy-radio-group (one choice) or hy-checkbox-group (any number). The card then draws its own native radio or checkbox, stretched over the card as the link is, so a click anywhere picks it, Space and Enter toggle it, the arrow keys move between the cards of a radio group, screen readers hear a radio or a checkbox named from the host (aria-labelledby pointing at the card's own heading, or aria-label), and the group owns the value, the form entry, the message line and required. selected says it is chosen (the group sets it) and draws the mark: a check in the top-end corner, on the brand on tokens. Chosen and current can both be true on one card — the plan you are on, picked again. A choice card is never also a link or clickable: one behavior per card. A single yes / no card is a checkbox group of one.

Hybrid SSR/CSR Section Visibility System ​

The card works seamlessly in both SSR and CSR-only (SPAs, Storybook) environments using a hybrid visibility approach:

In SSR environments:

  1. The host framework detects slot presence at render time using its own slot API
  2. Passes boolean attributes (has-body, has-footer) to the Lit component
  3. CSS uses these attributes for immediate section visibility during initial render
  4. After hydration, HasSlotController confirms slot content and adds .has-content classes

In CSR-only environments (SPAs, Storybook):

  1. All sections render to DOM (no has-* attributes)
  2. Sections are hidden by default via CSS (display: none)
  3. firstUpdated() runs and hideSectionsWithoutContent() detects actual content
  4. Adds .has-content classes which trigger CSS visibility rules

This approach ensures consistent behavior across all rendering environments while optimizing for SSR performance when available.

Rendering Strategy ​

Both sections (body, footer) are always rendered to the DOM. Visibility is controlled entirely through CSS, which provides:

  • Universal compatibility across SSR and CSR environments
  • No conditional rendering that could break in different contexts
  • Clean separation: JavaScript detects content, CSS controls visibility
  • Consistent DOM structure regardless of rendering environment

While this means empty sections exist in the DOM (hidden), the approach ensures maximum compatibility and follows standard component library patterns.

Accessibility: built in ​

  • Full keyboard navigation support (Enter/Space keys for clickable cards)
  • Proper ARIA attributes (role="button", aria-disabled, tabindex management)
  • High contrast mode support with enhanced focus indicators
  • Hidden-but-accessible slotted text via the .hy-visually-hidden utility (themes/visually-hidden.css)
  • Reduced motion support respecting user preferences

Themeable via the @cssprop override hooks documented below.

Accessibility: your part ​

  • Name a link card (href): aria-labelledby on the host pointing at the card's own heading, or aria-label when its name is in an image. The stretched link takes the name; a link card with neither warns in development.
  • Give an image in hy-card-media meaningful alt text, or alt="" when it only decorates.

When to use

  • A thing shown among others like it: a product, an article, a project, a plan.
  • One bounded piece of content with its own actions: a summary, a profile.
  • A choice among a few options that need more than a label, as a choice card in a radio or checkbox group.
  • The tile that adds one more to a set of cards (a new project, a new design system): a clickable card with dashed, first or last in the set.

Do

  • Give the cards of a set the same structure: the same parts in the same order.
  • Start the content with the title.

When not to use

  • A card that opens another page: set href. Keep clickable for a card that acts in place.
  • A card that holds its own buttons or links: leave clickable and href off and put the actions in the footer slot.
  • A lone value or label: plain text, or an hy-list-item in an hy-list.
  • A choice the user makes among cards: cards with a value inside an hy-radio-group or an hy-checkbox-group (the choice card), never clickable cards with your own state. active marks the one that is current (the plan you are on, the item open), not a selection to submit.
  • Groups inside a card: headings, or a rule in --hy-page-rule-color at --hy-page-rule-width (what a card holds is the page's own content), never a card inside a card.
  • Arranging the regions of a page: grid or flex in your own classes. A card is for one bounded piece of content.
  • An image beside the text (a horizontal card): a flush card with a grid inside, the image in one column and the text in the other with a Pages inset — a pattern, not a card option.
  • A picture across the card: hy-card-media in the content, never an <img> with its own negative margins.
  • A title row with an action at its end (a menu button, a link): the card has no header part and no action slot — the row is the page's own flex row at the top of the content, the title in a Page style.
  • A region that groups the screen's own content (a form group, a filter rail, a settings group): a Pages band with the region corner, the recessed-region style job. A card is a thing; choose emphasis="outlined" only when such a region needs a visible edge.

Examples ​

html
<!-- A card: its title, picture, body and actions are the page's own content in the
     card's slots (SSR frameworks can pass has-* hints for a faster first render) -->
<hy-card>
  <h3 class="card-title">Card title</h3>
  <hy-card-media>
    <img src="photo.jpg" alt="Card image" width="800" height="533" />
  </hy-card-media>
  <p>Main content goes here.</p>
  <div slot="footer">
    <button>Action</button>
  </div>
</hy-card>
html
<!-- Clickable card (full keyboard support in all environments) -->
<hy-card clickable aria-label="Open the report">
  <p>Click anywhere on this card to trigger an action.</p>
</hy-card>
html
<!-- The tile that adds one more to a set of cards: a clickable card with a dashed edge,
     named on the host, its content the page's own (here a plus icon). -->
<hy-card clickable dashed emphasis="outlined" aria-label="New project">
  <hy-icon name="plus" decorative></hy-icon>
</hy-card>
html
<!-- Navigation card: a stretched anchor covers the card, so middle-click,
     Ctrl+click, and the screen-reader links list work natively. The anchor's
     accessible name is the card's own heading, through aria-labelledby. -->
<hy-card href="/products/42" aria-labelledby="product-42">
  <h3 id="product-42" class="card-title">Product 42</h3>
  <p>Open the product page.</p>
</hy-card>
html
<!-- A navigation card with no heading — its name shown in an image, say a
     logo that holds it: aria-label on the host names the link. -->
<hy-card href="/tuner" aria-label="Tuner">
  <hy-card-media><img src="/tuner-logo.svg" alt="" /></hy-card-media>
  <p>Generate design systems that fit your brands.</p>
</hy-card>
html
<!-- The current one among several cards (the plan you are on, the item open):
     a brand edge, and aria-current for screen readers. Say it in words too. -->
<hy-card active>
  <h3 class="card-title">Pro plan</h3>
  <p>Your current plan. Unlimited projects.</p>
</hy-card>
html
<!-- The choice card: the item of a radio group, picked by a click anywhere on it, by
     Space or Enter, or by the arrow keys. The group owns the value and the form entry. -->
<hy-radio-group name="plan" label="Plan" value="team">
  <hy-card value="starter" aria-labelledby="plan-starter">
    <h3 id="plan-starter" class="card-title">Starter</h3>
    <p>For one person.</p>
  </hy-card>
  <hy-card value="team" aria-labelledby="plan-team">
    <h3 id="plan-team" class="card-title">Team</h3>
    <p>For a team of five.</p>
  </hy-card>
</hy-radio-group>
html
<!-- Direct usage in SPA/Storybook (no SSR wrapper needed) -->
<script type="module">
  import '@whitespaceux/harmony/card';
</script>
<hy-card>
  <p>No SSR required - works in any JavaScript environment!</p>
</hy-card>

API ​

Properties ​

PropertyAttributeTypeDefaultDescription
clickableclickablebooleanfalseMakes the entire card clickable and applies appropriate styling with keyboard support (Enter/Space keys).
hrefhrefstring | undefined—Renders the card as a navigation link: a stretched anchor covers the card, so middle-click / Ctrl+click / "open in new tab" and the screen-reader links list all work natively. The anchor's accessible name is the host's aria-labelledby (the card's own heading) or aria-label (a card whose name is in an image) — set one of them; a link card with neither warns in development. When href is set, clickable's button semantics and the card-click event are suppressed — navigation is the activation. Avoid combining with other interactive content inside the card (the anchor covers it).
targettargetstring | undefined—Anchor target — only meaningful when href is set. _blank auto-adds rel="noreferrer noopener".
disableddisabledbooleanfalseDisables the card, preventing all interaction and applying disabled styling. Takes precedence over all other states.
activeactivebooleanfalseMarks the card as the current one among several — the plan you are on, the design system open. Draws a brand edge, one stroke step heavier, on every emphasis, and announces it with aria-current="true" (on the link in href mode). Persistent state, not a selection: picking among cards is a radio group. disabled wins.
selectedselectedbooleanfalseThe card is chosen — the item of a radio or checkbox group that is picked. The group sets it from its value; set it yourself only on a card outside a group that shows a choice made elsewhere. Draws the check mark in the top-end corner. Chosen is not current: active is the plan you are on, selected the one you are picking.
valuevaluestring | undefined—The card's value as the item of a radio group or a checkbox group — what the group's value holds and the form submits when the card is chosen. With a value inside hy-radio-group or hy-checkbox-group the card is a choice card: it draws its own stretched native radio or checkbox, named from the host (aria-labelledby pointing at the card's heading, or aria-label). Outside a group a value does nothing.
elevatedelevatedbooleanfalseApplies a resting shadow to lift the card off the surface. Opt-in — flat by default.
flushflushbooleanfalseRemoves the card's inner padding, so its content runs to the card's edge — for a card whose content draws its own inset or is a picture to the edge (Polaris: padding none).
dasheddashedbooleanfalseDraws the card's edge dashed: the tile that adds one more to a set of cards (a new project, a new design system). Only on a card that acts (clickable or href); on any other card it draws nothing and warns in development, since a dashed box that does nothing reads as a place to drop something. Hover turns the edge to the brand edge color; the fill, the focus ring and the keyboard are the clickable card's own. Shows on an emphasis with an edge (solid, outlined). Off by default: a clickable card without it keeps its solid edge.
emphasisemphasis'solid' | 'outlined' | 'tinted' | 'plain''solid'Visual chrome weight on the card surface.
hasBodyhas-bodybooleanfalseSSR optimization hint indicating body slot has content.
hasFooterhas-footerbooleanfalseSSR optimization hint indicating footer slot has content.

Events ​

EventDetailDescription
card-click—Fired when a clickable card is activated via mouse or keyboard. Not fired in href mode — navigation is the activation.
change—Fired when a choice card (a card with a value in a radio or checkbox group) is picked or unpicked by the user. Detail: HyCheckedChangeDetail — { checked, value, reason: 'user' }. The group stops it and emits its own change.

Slots ​

SlotDescription
defaultThe main body content of the card; a picture in it goes in hy-card-media, which the card bleeds to its edges.
footerContent to display in the card footer section, pinned to the card's bottom when the card fills its height.

CSS Parts ​

PartDescription
baseThe card's base wrapper element
bodyThe main body content container
footerThe footer section container
linkThe stretched anchor rendered in href mode (covers the whole card)
choiceThe stretched native radio or checkbox of a choice card (covers the whole card)
check-markThe check in the top-end corner of a chosen card

CSS Custom Properties ​

The documented hooks. Every override hook the component reads — one per token it paints — is listed in @whitespaceux/harmony/theme-api.json, and @whitespaceux/harmony/component-bindings.json (the Harmony MCP get-bindings tool) says which part and state each one paints.

PropertyDefaultDescription
--hy-card-shadow-elevation-4var(--hy-shadow-elevation-4)Box shadow on hover/focus for clickable cards
--hy-card-shadow-elevation-5var(--hy-shadow-elevation-5)Box shadow of a raised card that acts, on hover and focus
--hy-card-background-surface-elevation-5var(--hy-background-surface-elevation-5)Surface fill of a raised card that acts, on hover and focus
--hy-card-background-surface-elevation-4var(--hy-background-surface-elevation-4)Surface fill when the card has [elevated]. (same as surface-base in light mode; lifts to a tonally-raised gray in dark mode so the card stays visible on OLED-true-black surfaces where the cast shadow alone can't darken). Set this to override per-card.
--hy-card-border-elevated-restvar(--hy-border-elevated-rest)Border color of a raised card ([elevated]): the rim of a surface that floats with a shadow
--hy-card-stroke-smvar(--hy-stroke-sm)Border width
--hy-card-padding-container-mdvar(--hy-padding-container-md)Inner padding of the header, body and footer
--hy-card-padding-layout-nonevar(--hy-padding-layout-none)Inner padding when flush
--hy-card-border-brand-restvar(--hy-border-brand-rest)Edge color of the current card ([active]), and of a dashed card on hover
--hy-card-stroke-mdvar(--hy-stroke-md)Edge width of the current card ([active])
--hy-card-radius-container-md-top-startvar(--hy-radius-container-md-top-start)Top-start corner radius override.
--hy-card-radius-container-md-top-endvar(--hy-radius-container-md-top-end)Top-end corner radius override. See top-start.
--hy-card-radius-container-md-bottom-startvar(--hy-radius-container-md-bottom-start)Bottom-start corner radius override. See top-start.
--hy-card-radius-container-md-bottom-endvar(--hy-radius-container-md-bottom-end)Bottom-end corner radius override. See top-start.
--hy-card-motion-transition-hover-durationvar(--hy-motion-transition-hover-duration)Transition duration for hover and focus state feedback
--hy-card-motion-transition-hover-easingvar(--hy-motion-transition-hover-easing)Transition easing for hover and focus state feedback
--hy-card-background-brand-on-restvar(--hy-background-brand-on-rest)Fill of the chosen card's check mark (the brand on token, C6)
--hy-card-foreground-brand-on-restvar(--hy-foreground-brand-on-rest)The check itself, on that fill
--hy-card-indicator-size-mdvar(--hy-indicator-size-md)Size of the check mark's disc
--hy-card-radius-pill-circlevar(--hy-radius-pill-circle)The disc's corner (a circle)
--hy-card-icon-size-smvar(--hy-icon-size-sm)Size of the check glyph inside the disc

Methods ​

focus() ​

The stretched input of a choice card takes the focus. (API parity: the React twin keeps its ref on the DOM element and has no handle for it — frozen in the baseline on purpose.)

Built with Lit. Documented with VitePress.