@8x8/oxygen-base-card
v3.0.0
Published
Oxygen - BaseCard
Keywords
Readme
@8x8/oxygen-base-card
This package replaces the deprecated @8x8/oxygen-card.
BaseCard is a composable surface for the 8x8 Oxygen design system. It provides the card shell — background, border, and border-radius — while leaving all internal layout and element semantics to the consumer via children and the as prop.
Installation
$ yarn add @8x8/oxygen-base-card$ npm install @8x8/oxygen-base-cardUsage
import BaseCard, {
CardSection,
CardRow,
CardMedia,
CardMediaInset,
CardTitle,
CardSubtitle,
} from '@8x8/oxygen-base-card';Props
BaseCard
type BaseCardProps — discriminated union keyed on as. Each variant carries the correct HTML element attributes for its element. Export BaseCardPropsCommon is available for consumers who need to extend the shared base props in custom components.
| Name | Type | Default | Description |
| :------------- | :------------------------------------------------------------------------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| as? | 'article' \| 'div' \| 'section' \| 'li' \| 'aside' \| 'a' \| 'button' \| React.ComponentType | 'article' | HTML element or component to render. Pass a React Router Link (or similar) for client-side navigation. Use 'a' for plain anchor navigation, 'button' for trigger cards, 'div' for widgets, 'li' or 'aside' inside list/aside contexts. |
| variant? | 'outline' \| 'elevated' | 'outline' | Visual surface treatment. outline uses a border; elevated uses a shadow. |
| density? | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' | 'md' | Controls the card padding via --base-card-padding. xs=8px · sm=12px · md=16px · lg=24px · xl=32px. |
| isClickable? | boolean | false | Enables hover state (background tint, border shift, cursor pointer). Set when the card or a stretched target inside it is interactive. |
| isSelected? | boolean | false | Highlights the card with a selection border when true. |
| radius? | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' | 'md' | Controls the card border-radius via --base-card-radius. xs=2px · sm=4px · md=6px · lg=12px · xl=16px. |
| testId? | string | 'CARD_BASE' | Test ID DOM attribute value. |
as="a" only — these props are only available (and type-safe) when as="a":
| Name | Type | Default | Description |
| :-------- | :------- | :---------- | :--------------------------------------- |
| href? | string | undefined | Destination URL for navigation cards. |
| target? | string | undefined | Browsing context (e.g. '_blank'). |
| rel? | string | undefined | Link relationship (e.g. 'noreferrer'). |
as="button" only — these props are only available (and type-safe) when as="button":
| Name | Type | Default | Description |
| :--------------- | :------------------------------------ | :--------- | :----------------------------------------------------------------------------------------------- |
| type? | 'button' | 'button' | Always 'button'. as="button" cards are trigger controls — opening a modal, sliding a panel, expanding a detail view. Using a card as a form submit control is poor UX: the entire card surface becomes the submit trigger (a large, easy-to-misclick target with no deliberate confirmation step), and there is no clear signal to the user that clicking will commit a form action. |
| disabled? | boolean | false | Disables the button. Prefer disabling only the controls inside the card rather than the card itself — see Disabled state. |
| aria-expanded? | boolean \| 'true' \| 'false' | undefined | Set when the card controls a disclosed region (e.g. a panel or drawer). Toggled by the click handler. |
All standard HTML attributes for the rendered element are accepted and forwarded to the root element.
Composition
BaseCard is a layout-agnostic shell. It accepts any children — the internal structure is entirely consumer-defined. There are no required wrappers, no mandatory slots, no prescribed layout primitives. Build your layout from whatever divs, styled components, or HTML elements your design needs.
BaseCard ships seven optional helpers that cover the most common layout and typography patterns. They are examples of what you can build, not constraints on how you must build. You can use them, ignore them, replace them, or mix them with your own primitives freely.
Shipped helpers (optional)
| Primitive | Element | Description |
| :------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------- |
| CardSection | div | Grouping wrapper. Flex column, no built-in gap. Extend with styled(CardSection) to add token-based gap or margin. No padding. |
| CardRow | div | Flex row with gap from theme.spacing03. Extend with styled(CardRow) to add token-based margin or override the gap. |
| CardMedia | div | Full-bleed image/video container. Uses negative margins to break out of the card's padding. img and video children are automatically styled (display: block; width: 100%; height: 100%; object-fit: cover) — no inline styles needed. Always place it at the leading edge of the card (top for column, left for row). Pass $direction="row" for horizontal cards — it controls which corners are rounded and which sides bleed. |
| CardMediaInset | div | Inset image/video container. Stays inside the card's padding, applies overflow: hidden and a fixed 4px border-radius. img and video children styled automatically — no inline styles needed. Control dimensions via style={{ height, width }}. |
| CardTitle | h2 | Primary card heading. Uses bodyBold02 type style and textColor01. Override the element with as="p" when the card is not in a document outline context (e.g. inside a <button> or <a>). |
| CardSubtitle | p | Secondary descriptor. Uses label01 type style and textColor02. |
| CardAbove | div | Wrapper for secondary controls (icon buttons, menus, links) inside an isClickable card. Emits data-above so the card's hover tint is suppressed when the user hovers the control rather than the card surface. Apply style={{ marginLeft: 'auto' }} to push it to the trailing edge. |
All seven accept all standard HTML attributes including style, className, and as. Extend any of them with styled() to override specific styles while keeping the base tokens.
Spacing
Inline style={{}} accepts plain values only. To use design system spacing tokens, extend CardSection or CardRow with styled() — this is the same pattern used throughout the Oxygen component library:
import styled from 'styled-components';
import { CardSection, CardRow } from '@8x8/oxygen-base-card';
const MetricSection = styled(CardSection)`
gap: ${({ theme }) => theme.spacing03};
margin-top: ${({ theme }) => theme.spacing05};
`;
const ActionRow = styled(CardRow)`
margin-top: ${({ theme }) => theme.spacing03};
justify-content: flex-end;
`;Use style={{}} only for layout values that have no token equivalent (flex: 1, marginLeft: 'auto', width: 140, etc.).
Trailing action pattern
To push an action to the trailing edge of a CardRow, set marginLeft: 'auto' on the action element or its wrapper:
<CardRow>
<CardTitle>Total calls</CardTitle>
<div style={{ marginLeft: 'auto' }}>
<OptionsMenu />
</div>
</CardRow>Variant
Set variant on BaseCard to control its surface treatment. Two values are available:
outline(default) — flat background with a 1px border. Use for standard cards in any context.elevated— border kept, drop shadow added (shadowLow). Use when the card needs to stand out visually from its background, such as on a canvas or inside a panel.
<BaseCard variant="outline">
<CardSection>...</CardSection>
</BaseCard>
<BaseCard variant="elevated">
<CardSection>...</CardSection>
</BaseCard>Both variants support all interactive states (isClickable, isSelected).
Density
Set density on BaseCard to control its padding. This sets the --base-card-padding CSS custom property, which is applied directly to BaseCard's own padding:
<BaseCard density="md">
<CardSection>...</CardSection>
</BaseCard>Radius
Set radius on BaseCard to control its border-radius. This sets the --base-card-radius CSS custom property:
<BaseCard radius="md">
<CardSection>...</CardSection>
</BaseCard>Content states
BaseCard has no built-in state logic. Manage loading, error, empty, refreshing, and normal states with React state and conditionally render different content inside a CardSection. Set aria-busy="true" whenever the card is loading or refreshing.
import { useState } from 'react';
import styled from 'styled-components';
import { SkeletonBlock } from '@8x8/oxygen-skeletons';
import { Spinner } from '@8x8/oxygen-loaders';
const MetricSection = styled(CardSection)`
margin-top: ${({ theme }) => theme.spacing03};
`;
const [state, setState] = useState<'loading' | 'refreshing' | 'error' | 'empty' | 'normal'>('loading');
<BaseCard aria-busy={state === 'loading' || state === 'refreshing'}>
<CardSection style={{ gap: 0 }}>
<CardTitle>Total calls</CardTitle>
<CardSubtitle>Last 30 days</CardSubtitle>
</CardSection>
<MetricSection>
{state === 'loading' && (
<>
<SkeletonBlock size="body01" />
<SkeletonBlock size="body01" style={{ width: '75%' }} />
</>
)}
{state === 'refreshing' && (
<Spinner style={{ marginLeft: 'auto' }} size="small" text="Refreshing Total calls" />
)}
{state === 'error' && (
<p role="alert">Failed to load data.</p>
)}
{state === 'empty' && (
<p role="status">No calls in this period.</p>
)}
{state === 'normal' && (
<p>12,840</p>
)}
</MetricSection>
</BaseCard>Use SkeletonBlock for initial load (no data yet) and Spinner for partial refresh (title stays visible, only content reloads).
Horizontal layouts
Pass style={{ flexDirection: 'row' }} to BaseCard. If using the shipped CardMedia helper, also pass $direction="row" — it controls which corners receive the card radius (left edge) and which bleed out (top, bottom, left). Add style={{ flex: 1 }} to CardSection to fill remaining space:
<BaseCard style={{ flexDirection: 'row' }}>
<CardMedia $direction="row" style={{ width: 140 }}>
<img src="..." alt="" />
</CardMedia>
<CardSection style={{ flex: 1, gap: 0 }}>
<CardTitle>Title</CardTitle>
<CardSubtitle>Subtitle</CardSubtitle>
</CardSection>
<div style={{ marginLeft: 'auto' }}>
<Button size="small">Action</Button>
</div>
</BaseCard>Media full-bleed
Use CardMedia for images that sit flush against the card edges, breaking out of the card's padding. Always place it at the leading edge of the card — before any CardSection in column layouts, or as the first child in row layouts. The corner radius and bleed margins are determined by $direction, not by DOM position.
const MediaContent = styled(CardSection)`
gap: ${({ theme }) => theme.spacing03};
margin-top: ${({ theme }) => theme.spacing05};
`;
<BaseCard>
<CardMedia style={{ height: 140 }}>
<img src="..." alt="" />
</CardMedia>
<MediaContent>
<CardTitle>Title</CardTitle>
<CardSubtitle>Subtitle</CardSubtitle>
</MediaContent>
</BaseCard>Inset media
Use CardMediaInset for images surrounded by the card's padding on all sides. It applies overflow: hidden and a fixed 4px border-radius — independent of the card's own radius — so img and video children need no inline styles:
const InsetSection = styled(CardSection)`
gap: ${({ theme }) => theme.spacing03};
`;
<BaseCard>
<InsetSection>
<CardTitle>Title</CardTitle>
<CardSubtitle>Subtitle</CardSubtitle>
<CardMediaInset style={{ height: 160 }}>
<img src="..." alt="" />
</CardMediaInset>
<p>Supporting text goes here.</p>
</InsetSection>
</BaseCard>CardMedia breaks out of the card's padding for full-bleed edges. CardMediaInset stays within it.
Disabled state
BaseCard has no disabled prop — disabling an entire card is generally poor UX. A greyed-out card gives the user no signal about why it is unavailable or what they can do about it, while still displaying content they cannot act on.
Prefer one of these patterns instead:
- Disable only the controls inside — keep the card readable and let only the specific action (button, checkbox, radio) carry the disabled state. This is the right call for most cases.
- Show an unavailable state with explanation — keep the card fully visible but include a reason inline ("Requires admin access", "Upgrade to unlock"). Far more useful than a grey card.
- Remove the card — if the item truly should not be actionable for this user, omit it entirely rather than showing a dead card.
Loading and not-yet-available states are already covered by the content states pattern (aria-busy, skeleton, spinner) and do not need a disabled card.
Clickable cards
Interactive cards come in distinct modes. Picking the wrong one silently breaks keyboard navigation, screen reader announcements, or both. Add isClickable to enable the hover state and cursor: pointer.
| Scenario | Pattern | Key requirement |
|---|---|---|
| Navigate to a page, no other interactive elements | as={Link} (React Router) or as="a" on BaseCard | Meaningful aria-label if title alone is ambiguous |
| Trigger an action, no other interactive elements | as="button" on BaseCard | aria-expanded when controlling a disclosed region |
| Selection (multi or single) | Checkbox / Radio in a trailing slot inside CardRow, isSelected on card | aria-label on each control; group wrapped in role="group" |
| Navigate + secondary controls (exception) | stretched <a data-stretched-target> + position: relative wrapper | Meaningful aria-label on the stretched element; one secondary control max |
| Trigger + secondary controls (exception) | stretched <button data-stretched-target> + position: relative wrapper | Same cautions as above |
Stretched target cautions. The stretched <a> / <button> pattern carries unconditional tradeoffs that apply to every user, all the time:
data-stretched-targetis required — add it to both stretched<a>and<button>elements. Without it the card's hover and active tints are suppressed because the stretched element covers the full card surface and intercepts pointer events.- Text is not selectable — the stretched element sits on top of all content in paint order. Users cannot drag-select text inside the card; on mobile, long-press triggers the action instead of the selection handle. Avoid when card content includes values users might need to copy (IDs, names, numbers).
- Misclick risk — any click on the card surface outside the secondary control triggers the primary action. The preferred alternative is a non-interactive card with a standalone link or button as the title.
See the Stretched target, Navigate, Trigger, and Selection examples in Storybook for full implementation details.
Accessibility
Root element
Use as to match the card's semantic role to its context (default is 'article'):
<BaseCard> {/* article — self-contained content card */}
<BaseCard as="div"> {/* div — dashboard widget, no landmark semantics */}
<BaseCard as="section"> {/* section — grouped content within a larger article */}
<BaseCard as="li"> {/* li — card inside a grid list */}Provide an accessible name via aria-label or aria-labelledby. Set aria-busy="true" while content is loading or refreshing.
Card grid list semantics
When rendering a grid of related cards, wrap the grid in a <ul> with <li> wrappers around each card. Screen readers then announce "list of N items" and "item X of N", which orients keyboard users efficiently:
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{queues.map(queue => (
<li key={queue.id}>
<BaseCard as="article">
...
</BaseCard>
</li>
))}
</ul>Or use as="li" directly on BaseCard to avoid a redundant wrapper:
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{queues.map(queue => (
<BaseCard key={queue.id} as="li" aria-label={queue.label}>
...
</BaseCard>
))}
</ul>Card title
CardTitle defaults to h2. Use the as prop to adjust the heading level to fit the surrounding page hierarchy, or opt out of heading semantics entirely for widget labels:
<CardTitle>Agent card</CardTitle> {/* h2 — content card heading */}
<CardTitle as="h3">Nested card</CardTitle> {/* h3 — inside a section already headed by h2 */}
<CardTitle as="p">Total calls</CardTitle> {/* p — dashboard widget label, not a heading */}