npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@8x8/oxygen-base-card

v3.0.0

Published

Oxygen - BaseCard

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-card

Usage

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-target is 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 */}