Orbex Sonar-DB Design system , home

Anatomy

  1. Title, which also names the card as a region of the page.
  2. Unit menu: the card’s only setting, the unit its figures are shown in. Changing it converts every figure in the card. See Unit menu.
  3. Summary: one figure that sums the card up, a label and a value over a rule.
  4. Content: the detail, at most two levels deep: sections with a small heading, rows, a chart.
  5. Scrolling list, optional: the part of the content that gives way first when the size leaves no room. See Card scroll.
  6. Footer: the way to the full page, and at most one action, such as New order. See Card link.

In edit mode

  1. Move handle: a pill centered on the top edge. Drag it to move the card; with the keyboard, Space picks the card up and drops it.
  2. Remove: a red ring outside the top end corner. The card leaves the overview and can be added back from New element.
  3. Dashed edge in accent: the card is part of a draft until Save layout.
  4. Resize handle: a pill centered on the bottom edge. Pull it, or use the up and down arrows, to step through the sizes the card offers.
  5. Size: S, M, L or XL, in a ring beside the resize handle.

The six cards of the overview

Orders · M

Orders

Open total6,720 plt

May 2026

  • 1,420 pltshipped
  • 80 plton hold
  • 1,100 pltpending

June 2026

  • 1,500 pltshipped
  • 160 plton hold
  • 1,100 pltpending

New order

Create a new purchase order.

Code: a self-contained version
tsx
/* Orders, size M: the open total, two months as tick bars, New order and the way to the Orders page */
import { Card, CardLink, UnitMenu } from '@orbex/sonar-components/card'
import { ChartLegend } from '@orbex/sonar-components/chart-legend'
import { IconButton } from '@orbex/sonar-components/icon-button'
import { TickBar } from '@orbex/sonar-components/tick-bar'
import '@orbex/sonar-components/card.css'
import '@orbex/sonar-components/chart-legend.css'
import '@orbex/sonar-components/icon-button.css'
import '@orbex/sonar-components/menu.css'
import '@orbex/sonar-components/tick-bar.css'
import addCircle from '@material-symbols/svg-400/rounded/add_circle.svg?raw'
import { useState } from 'react'

const AddIcon = () => (
  <svg viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true">
    <path d={addCircle.match(/ d="([^"]+)"/)?.[1]} />
  </svg>
)

const units = [
  { value: 'plt', short: 'plt', name: 'Pallets' },
  { value: 'kg', short: 'kg', name: 'Kilograms' },
]
const perPallet: Record<string, number> = { plt: 1, kg: 420 }

const months = [
  { month: 'May 2026', shipped: 1420, onHold: 80, pending: 1100 },
  { month: 'June 2026', shipped: 0, onHold: 160, pending: 2600 },
]
const parts = [
  { key: 'shipped', label: 'Shipped', tone: 'accent' },
  { key: 'onHold', label: 'On hold', tone: 'strong' },
  { key: 'pending', label: 'Pending', tone: 'muted' },
] as const

export function OrdersCard({ onNewOrder }: { onNewOrder: () => void }) {
  const [unit, setUnit] = useState('plt')
  const format = (pallets: number) => `${(pallets * perPallet[unit]!).toLocaleString('en-US')} ${unit}`
  const open = months.reduce((sum, { onHold, pending }) => sum + onHold + pending, 0)
  return (
    <Card
      title="Orders"
      size="m"
      actions={<UnitMenu label="Unit" value={unit} options={units} onValueChange={setUnit} />}
      summary={{ label: 'Open total', value: format(open) }}
      footer={<CardLink href="/orders">Go to Orders</CardLink>}
    >
      {months.map((month) => (
        <section key={month.month} aria-label={month.month} style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
          <h3 style={{ margin: 0, font: 'var(--sonar-type-label-md)' }}>{month.month}</h3>
          <TickBar
            label={`${month.month} orders`}
            format={format}
            segments={parts.map(({ key, label, tone }) => ({ label, tone, value: month[key] }))}
          />
        </section>
      ))}
      <ChartLegend aria-hidden="true" items={parts.map(({ label, tone }) => ({ label, tone }))} />
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 16 }}>
        <span>New order</span>
        <IconButton variant="tonal" label="New order" icon={<AddIcon />} aria-haspopup="dialog" onClick={onNewOrder} />
      </div>
    </Card>
  )
}
Inventory · S

Inventory

Total2,800 plt

840 plt1,960 plt
On holdAvailable
Code: a self-contained version
tsx
/* Inventory, size S: the total, and on hold against available as a ring */
import { Card, UnitMenu } from '@orbex/sonar-components/card'
import { RingChart } from '@orbex/sonar-components/ring-chart'
import '@orbex/sonar-components/card.css'
import '@orbex/sonar-components/menu.css'
import '@orbex/sonar-components/ring-chart.css'
import { useState } from 'react'

const units = [
  { value: 'plt', short: 'plt', name: 'Pallets' },
  { value: 'kg', short: 'kg', name: 'Kilograms' },
]
const perPallet: Record<string, number> = { plt: 1, kg: 420 }
const stock = { available: 1960, onHold: 840 }

export function InventoryCard() {
  const [unit, setUnit] = useState('plt')
  const format = (pallets: number) => `${(pallets * perPallet[unit]!).toLocaleString('en-US')} ${unit}`
  const parts = [
    { label: 'On hold', tone: 'danger', value: stock.onHold },
    { label: 'Available', tone: 'muted', value: stock.available },
  ] as const
  return (
    <Card
      title="Inventory"
      size="s"
      actions={<UnitMenu label="Unit" value={unit} options={units} onValueChange={setUnit} />}
      summary={{ label: 'Total', value: format(stock.available + stock.onHold) }}
    >
      <div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', gap: 12 }}>
        <p>{format(stock.onHold)}<br />On hold</p>
        <RingChart label="Inventory" direction="counterclockwise" format={format} segments={[...parts]} style={{ width: 96 }} />
        <p style={{ textAlign: 'end' }}>{format(stock.available)}<br />Available</p>
      </div>
    </Card>
  )
}
Daily shipments · XL

Daily shipments

Month to date4,485 plt

May 2026


Today
350 plt
Yesterday
370 plt
This month
4,485 plt
Returns
40 plt

Code: a self-contained version
tsx
/* Daily shipments, size XL: the month to date, its key figures and the month’s line */
import { Card, CardLink, UnitMenu } from '@orbex/sonar-components/card'
import { TrendChart } from '@orbex/sonar-components/trend-chart'
import '@orbex/sonar-components/card.css'
import '@orbex/sonar-components/menu.css'
import '@orbex/sonar-components/trend-chart.css'
import { useState } from 'react'

const units = [
  { value: 'plt', short: 'plt', name: 'Pallets' },
  { value: 'kg', short: 'kg', name: 'Kilograms' },
]
const perPallet: Record<string, number> = { plt: 1, kg: 420 }

/* May up to the 12th: the days still to come are null and leave the line open */
const shipped = [320, 410, 360, 300, 455, 385, 290, 430, 470, 345, 370, 350]
const days = Array.from({ length: 31 }, (_, index) => `May ${index + 1}`)
const values = days.map((_, index) => shipped[index] ?? null)

export function ShipmentsCard() {
  const [unit, setUnit] = useState('plt')
  const inUnit = (pallets: number) => pallets * perPallet[unit]!
  const format = (pallets: number) => `${inUnit(pallets).toLocaleString('en-US')} ${unit}`
  const total = shipped.reduce((sum, value) => sum + value, 0)
  const stats = [
    ['Today', shipped.at(-1)!],
    ['Yesterday', shipped.at(-2)!],
    ['This month', total],
    ['Returns', 40],
  ] as const
  return (
    <Card
      title="Daily shipments"
      size="xl"
      actions={<UnitMenu label="Unit" value={unit} options={units} onValueChange={setUnit} />}
      summary={{ label: 'Month to date', value: format(total) }}
      footer={<CardLink href="/shipments">Go to Shipments</CardLink>}
    >
      <dl style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
        {stats.map(([term, value]) => (
          <div key={term}>
            <dt>{term}</dt>
            <dd style={{ margin: 0 }}>{format(value)}</dd>
          </div>
        ))}
      </dl>
      <TrendChart
        label={`Daily shipments in May 2026, ${format(total)} so far`}
        categories={days}
        series={{ name: `Shipments (${unit})`, values: values.map((value) => (value === null ? null : inUnit(value))) }}
        cumulative={{ name: `Cumulative (${unit})` }}
        unit={unit}
        axisLabels={[0, 14, days.length - 1]}
      />
    </Card>
  )
}
Balance · M

Balance

Total balance3,900,000.00 USD

Overdue

2,230,800.00 USD

57.2%


Due today

46,800.00 USD1.2%

Code: a self-contained version
tsx
/* Balance, size M: the balance, its overdue share as a tick bar, and the invoices due today */
import { Card, CardLink, UnitMenu } from '@orbex/sonar-components/card'
import { ChartLegend } from '@orbex/sonar-components/chart-legend'
import { Status } from '@orbex/sonar-components/status'
import { TickBar } from '@orbex/sonar-components/tick-bar'
import '@orbex/sonar-components/card.css'
import '@orbex/sonar-components/chart-legend.css'
import '@orbex/sonar-components/menu.css'
import '@orbex/sonar-components/status.css'
import '@orbex/sonar-components/tick-bar.css'
import { useState } from 'react'

const currencies = [
  { value: 'USD', short: 'USD', name: 'US dollars' },
  { value: 'EUR', short: 'EUR', name: 'Euros' },
]
const perDollar: Record<string, number> = { USD: 1, EUR: 0.92 }
const balance = { total: 3_900_000, overdue: 2_230_800 }
/* The invoices due today */
const today = [
  { id: 'INV-7712', amount: 30_000, status: 'Due today', tone: 'warning' },
  { id: 'INV-7716', amount: 16_800, status: 'Paid', tone: 'positive' },
] as const

export function BalanceCard() {
  const [currency, setCurrency] = useState('USD')
  const format = (dollars: number) => `${Math.round(dollars * perDollar[currency]!).toLocaleString('en-US')} ${currency}`
  const parts = [
    { label: 'Overdue', tone: 'danger', value: balance.overdue },
    { label: 'Not due yet', tone: 'muted', value: balance.total - balance.overdue },
  ] as const
  return (
    <Card
      title="Balance"
      size="m"
      actions={<UnitMenu label="Currency" value={currency} options={currencies} onValueChange={setCurrency} />}
      summary={{ label: 'Balance', value: format(balance.total) }}
      footer={<CardLink href="/balance">Go to Balance</CardLink>}
    >
      <TickBar label="Overdue share of the balance" format={format} segments={[...parts]} />
      <ChartLegend items={parts.map(({ label, tone, value }) => ({ label, tone, value: format(value) }))} aria-hidden="true" />
      <ul role="list" style={{ display: 'grid', gap: 8, marginTop: 16, padding: 0 }}>
        {today.map(({ id, amount, tone, status }) => (
          <li key={id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
            <span>{id}</span>
            <span>{format(amount)}</span>
            <Status tone={tone}>{status}</Status>
          </li>
        ))}
      </ul>
    </Card>
  )
}
Deliveries · L

Deliveries

May 2026

Calendar view
May 12TuesdayTotal 415 plt
  • 04-118B2 items40 pltDelivered
  • 04-121A7 items150 pltDelayed
  • 04-127C1 item15 pltDelayed
  • 04-130A8 items210 pltDelivered
Code: a self-contained version
tsx
/* Deliveries, size L: the deliveries of the chosen day, in a list that scrolls first when the card runs out of room */
import { Button } from '@orbex/sonar-components/button'
import { Card, CardLink, CardScroll, UnitMenu } from '@orbex/sonar-components/card'
import { Status } from '@orbex/sonar-components/status'
import '@orbex/sonar-components/button.css'
import '@orbex/sonar-components/card.css'
import '@orbex/sonar-components/menu.css'
import '@orbex/sonar-components/status.css'
import { useState } from 'react'

const units = [
  { value: 'plt', short: 'plt', name: 'Pallets' },
  { value: 'kg', short: 'kg', name: 'Kilograms' },
]
const perPallet: Record<string, number> = { plt: 1, kg: 420 }

const deliveries = [
  { id: '04-118B', items: 2, pallets: 40, status: 'Delivered', tone: 'positive' },
  { id: '04-121A', items: 7, pallets: 150, status: 'Delayed', tone: 'warning' },
  { id: '04-127C', items: 1, pallets: 15, status: 'Delayed', tone: 'warning' },
  { id: '04-130A', items: 8, pallets: 210, status: 'Delivered', tone: 'positive' },
] as const

export function DeliveriesCard({ onView }: { onView: (id: string) => void }) {
  const [unit, setUnit] = useState('plt')
  const format = (pallets: number) => `${(pallets * perPallet[unit]!).toLocaleString('en-US')} ${unit}`
  return (
    <Card
      title="Deliveries"
      size="l"
      actions={<UnitMenu label="Unit" value={unit} options={units} onValueChange={setUnit} />}
      footer={<CardLink href="/deliveries">Go to Deliveries</CardLink>}
    >
      <h3 style={{ margin: '0 0 8px', font: 'var(--sonar-type-label-md)' }}>Tuesday, May 12</h3>
      <CardScroll label="Deliveries of May 12">
        <ul role="list" style={{ padding: 0 }}>
          {deliveries.map(({ id, items, pallets, status, tone }) => (
            <li key={id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0', borderBottom: '1px solid var(--sonar-color-divider)' }}>
              <span style={{ flex: 1 }}>
                {id} · {items} {items === 1 ? 'item' : 'items'} · {format(pallets)}
              </span>
              <Status tone={tone}>{status}</Status>
              <Button size="small" variant="text" aria-label={`View delivery ${id}`} onClick={() => onView(id)}>
                View
              </Button>
            </li>
          ))}
        </ul>
      </CardScroll>
    </Card>
  )
}
Recurring orders · L

Recurring orders, quick action

Selected products1

  • Packaging CB-4030BoxStock: 380 plt
  • Take a look!new recycled-fiber boxes.

  • Packaging SF-2300RollStock: 560 plt
  • Try instead
    SF-2000410 plt

  • Packaging KP-1200RollStock: 210 plt
  • Packaging EP-5050UnitStock: 940 plt
  • Packaging CB-2010BoxStock: 640 plt
  • Packaging SF-1700RollStock: 320 plt
  • Take a look!thinner film, same hold.

  • Packaging TS-1210UnitStock: 150 plt
  • Packaging SB-1200RollStock: 480 plt
Code: a self-contained version
tsx
/* Recurring orders, size L: the products ordered most often, to choose and reorder in one step */
import { Button } from '@orbex/sonar-components/button'
import { Card, CardLink, CardScroll } from '@orbex/sonar-components/card'
import { Checkbox } from '@orbex/sonar-components/checkbox'
import '@orbex/sonar-components/button.css'
import '@orbex/sonar-components/card.css'
import '@orbex/sonar-components/checkbox.css'
import { useState } from 'react'

const products = [
  { code: 'CB-4030', name: 'Corrugated box / Double wall', stock: 380 },
  { code: 'SF-2300', name: 'Stretch film 23 µm', stock: 560 },
  { code: 'KP-1200', name: 'Kraft paper 120 g', stock: 210 },
  { code: 'EP-5050', name: 'Edge protector 50×50', stock: 940 },
  { code: 'CB-2010', name: 'Corrugated box / Single wall', stock: 640 },
]

export function RecurringCard({ onReorder }: { onReorder: (codes: string[]) => void }) {
  const [chosen, setChosen] = useState(['CB-4030'])
  const toggle = (code: string, on: boolean) => setChosen((current) => (on ? [...current, code] : current.filter((other) => other !== code)))
  return (
    <Card
      title="Recurring orders"
      size="l"
      footer={
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
          <CardLink href="/market">Go to Orbex Market</CardLink>
          <Button disabled={chosen.length === 0} onClick={() => onReorder(chosen)}>
            Reorder
          </Button>
        </div>
      }
    >
      <CardScroll label="Products to reorder">
        <ul role="list" style={{ display: 'grid', gap: 12, padding: 0 }}>
          {products.map(({ code, name, stock }) => (
            <li key={code}>
              <Checkbox
                label={name}
                description={`Packaging ${code} · ${stock.toLocaleString('en-US')} plt in stock`}
                checked={chosen.includes(code)}
                onChange={(event) => toggle(code, event.currentTarget.checked)}
              />
            </li>
          ))}
        </ul>
      </CardScroll>
    </Card>
  )
}
The layout styles the cards share
css
/* Layout inside the overview cards. Only arrangement lives here: every color, type and size comes from the
   components and the tokens. */

@import './typography.css';

/* ---------- Card tokens ----------
   Distances inside the cards are steps of the spacing scale. A few values are not, because a card's rhythm depends on
   them; each is named here with its reason, and nothing else in this file uses a bare distance. */
.sonar-card {
  /* Deliveries and Balance: a row of a day is 38 px between rules, one line of 14 px text, so its air is 11.5 px
     above and below. Rows of every card line up with it */
  --card-row-height: 38px;
  --card-row-line: 14px;
  --card-row-air: calc((var(--card-row-height) - var(--card-row-line) - 1px) / 2);
  /* Deliveries: the band that holds the chosen day between two rules. 41 px gives the date the same air as a row,
     and the rules stay put while the rows scroll */
  --card-day-band: 41px;
  /* Recurring orders: one distance between anything and its rule, so a product row is 80 px: 13 + 54 + 13 */
  --card-list-rhythm: 13px;
  --card-thumbnail: 54px;
}

.card-pager {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin: var(--sonar-space-10) calc(-1 * var(--sonar-space-8)) 0;
}

/* Two icon buttons that step through days or months */
.card-steps {
  display: inline-flex;
  margin: calc(-1 * var(--sonar-space-4)) calc(-1 * var(--sonar-space-8)) calc(-1 * var(--sonar-space-4)) 0;
}

.card-footer-row {
  display: flex;
  flex: 1;
  gap: var(--sonar-space-12);
  justify-content: space-between;
  align-items: flex-end;
}

/* The thin bar between the parts of a line of metadata; wider in the rows of a day */
.sep {
  margin: 0 3px;
  font-weight: 300;
}

.cal-rows .sep {
  margin: 0 5px;
}

/* ---------- Orders ---------- */

.orders-months {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 26px;
}

.orders-month {
  display: grid;
  align-content: start;
}

/* Three figures a month, 22.5 px apart */
.orders-month ul {
  display: grid;
  gap: 9px;
  margin: 9px 0 0;
}

.orders-month li {
  display: flex;
  gap: 10px;
  align-items: center;
  height: 14.4px;
}

.orders-month li .legend-dot {
  margin-left: 1px;
}

/* Pending, the part still to come, reads at the metadata level */
.orders-month li:last-child .card-figure {
  color: var(--sonar-color-text-meta);
}

.orders-month .sonar-tick-bar {
  /* 8 px above the 14 px tick bar of 1.0.0-alpha.12 (10 px until 1.0.0-alpha.16, 13 px above the 11 px bar before):
     the 2 px go to the space under the bars, and the card's content still fits its height */
  margin-top: 8px;
}

.legend-dot {
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--_dot);
}

.legend-dot[data-tone='accent'] { --_dot: var(--sonar-color-chart-primary); }
.legend-dot[data-tone='strong'] { --_dot: var(--sonar-color-chart-secondary); }
.legend-dot[data-tone='muted'] { --_dot: var(--sonar-color-chart-rest); }
.legend-dot[data-tone='danger'] { --_dot: var(--sonar-color-chart-alert); }

/* M: the named pager, the legend centered under it, then New order between two rules. The pager is 11 px, black,
   with bold chevrons, whether or not a step can move */
/* The months' pager and the legend stand apart from the figures and bars above them (1.0.0-alpha.16): 19 px from the
   bars to the pager (15 before) and 10 px from the pager to the legend (8 before); New order comes 8 px under the
   legend (12 before), so the card's content still fits its height. Written on .sonar-card so the app demos, whose
   product CSS reverts every margin (.oxa :where(*)), keep them too: there the pager touched the bars */
.sonar-card .orders-steps {
  margin: 19px 0 0;
}

.orders-steps .sonar-button,
.orders-steps .sonar-button[aria-disabled='true'] {
  --_ink: var(--sonar-color-text-strong);
  --_padding: 0;
  gap: 6px;
  --_icon: 18px;
  height: 18px;
  padding-inline: 0;
  font: var(--sonar-font-weight-medium) 11px/1.2 var(--sonar-font-family-sans);
  letter-spacing: -0.2px;
}

.orders-steps .sonar-button svg {
  width: 18px;
  height: 18px;
}

.orders-legend.sonar-chart-legend,
.orders-pager .sonar-chart-legend {
  gap: 30px;
  justify-content: center;
}

.sonar-card .orders-legend {
  margin: 10px 0 0;
}

.orders-legend > li,
.orders-pager .sonar-chart-legend > li {
  gap: 9px;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-font-weight-medium) 11px/1.2 var(--sonar-font-family-sans);
  letter-spacing: -0.2px;
}

.orders-legend > li::before,
.orders-pager .sonar-chart-legend > li::before {
  width: 5px;
  height: 5px;
}

/* New order: a pale, rounded square with the outlined plus */
.sonar-icon-button.orders-new-add {
  width: 44px;
  height: 44px;
  margin-right: 12px;
  border-radius: 16px;
  /* 1.1 (pending #19): background-color, not the shorthand, so the button keeps the state layers of IconButton (the
     shorthand, unlayered, wiped them and New order had no hover) */
  background-color: var(--sonar-color-surface-well);
  color: var(--sonar-color-accent);
}

/* v38 .addbtn:hover: the well turns neutral 90 (dark: neutral 27), with no tint over it */
@media (hover: hover) {
  .sonar-icon-button.orders-new-add:hover:not(:disabled) {
    background-color: var(--sonar-color-surface-well-hover);
    background-image: none;
  }
}

.sonar-icon-button.orders-new-add svg {
  width: 20px;
  height: 20px;
}

.sonar-card .orders-new {
  box-sizing: border-box;
  display: flex;
  gap: var(--sonar-space-12);
  justify-content: space-between;
  align-items: center;
  /* The same room above and below the add button, so the button and the text sit centred between the lines (988©STUDIO,
     1.0.0-alpha.23: the product has 9 px above and 6 px below; the 15 px are shared, so the card keeps its height) */
  margin-top: 8px;
  padding: 7.5px 0;
  border-top: 1px solid var(--sonar-color-divider);
  border-bottom: 1px solid var(--sonar-color-divider);
}

/* Its two lines centred between the rules too: the heading's line box is 0.8 px taller above its letters than the
   line under it is below them, so the pair moves 0.4 px down (alpha.23) */
.sonar-card .orders-new > div:first-child {
  margin-top: 0.8px;
}

/* The line under New order sits 2 px below it, as in the product */
.orders-new p {
  margin: 2px 0 0;
}

/* S: the same figures, a little higher; the pager sits around the legend at the foot */
.sonar-card[data-size='s'] .orders-months {
  margin-top: calc(-1 * var(--sonar-space-4));
}

.sonar-card[data-size='s'] .orders-month .sonar-tick-bar {
  margin-top: var(--sonar-space-10);
}

/* High enough that the chevrons' touch targets stay inside the card, so it never scrolls on phones */
.orders-pager {
  margin: calc(-1 * var(--sonar-space-4)) calc(-1 * var(--sonar-space-8)) calc(-1 * var(--sonar-space-6));
}

.orders-pager .sonar-chart-legend {
  justify-content: center;
}

/* ---------- Inventory ---------- */

/* The ring is centered on the card; each figure sits 15 px from it, On hold a little above the ring's middle and
   Available a little below. The ring keeps its size (128 px, 160 in M) and only shrinks, down to 72 px, when the
   widest figure (--label, measured) would not fit beside it. The names sit at the foot. */
.inventory {
  --gap: 15px;
  --ring-min: 72px;
  --ring-max: 128px;
  --cy: 76px;
  --ring: clamp(var(--ring-min), 100% - 2 * (var(--gap) + var(--label, 0px)), var(--ring-max));

  position: relative;
  height: 144px;
  margin-top: -12px;
}

.inventory[data-size='m'] {
  --ring-max: 160px;
  --cy: calc(50% - 12px);

  flex: 1;
  height: auto;
}

.inventory-ring {
  position: absolute;
  top: var(--cy);
  left: 50%;
  width: var(--ring);
  margin: 0;
  translate: -50% -50%;
}

.inventory-figure {
  position: absolute;
  color: var(--sonar-color-text-meta);
  font: var(--sonar-font-weight-semibold) 14px/1.4 var(--sonar-font-family-sans);
  letter-spacing: -0.2px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.inventory-figure[data-part='onHold'] {
  top: calc(var(--cy) - 31.7px);
  right: calc(50% + var(--ring) / 2 + var(--gap));
  color: var(--sonar-color-text-danger);
  text-align: right;
}

.inventory-figure[data-part='available'] {
  top: calc(var(--cy) - 9.8px);
  left: calc(50% + var(--ring) / 2 + var(--gap));
}

.inventory-name {
  position: absolute;
  bottom: 0;
  display: flex;
  gap: 6px;
  align-items: center;
  color: var(--sonar-color-text-meta);
  font: var(--sonar-font-weight-medium) 14px/1.2 var(--sonar-font-family-sans);
}

.inventory-name i {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--sonar-color-chart-rest);
}

.inventory-name[data-part='onHold'] {
  left: 2px;
  color: var(--sonar-color-text-danger);
}

.inventory-name[data-part='onHold'] i {
  background: var(--sonar-color-chart-alert);
}

.inventory-name[data-part='available'] {
  right: 0;
}

/* ---------- Daily shipments ---------- */

.card-heading.shipments-month {
  margin-top: var(--sonar-space-2);
}

.card-rule.shipments-rule {
  margin: var(--sonar-space-12) 0 var(--sonar-space-16);
}

.shipments-stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sonar-space-14) var(--sonar-space-16);
  margin: 0;
}

.shipments-stats {
  margin-top: calc(-1 * var(--sonar-space-2));
}

.shipments-stats + .shipments-rule {
  margin: var(--sonar-space-14) 0 18px;
}

.shipments-stats div {
  display: flex;
  gap: var(--sonar-space-4);
  height: 14px;
  align-items: center;
}

.shipments-stats div:nth-child(even) {
  justify-content: flex-end;
}

.shipments-stats dt {
  color: var(--sonar-color-text-strong);
  font: 400 12px/1.2 var(--sonar-font-family-sans);
  letter-spacing: -0.4px;
}

.shipments-stats dt::after {
  content: ':';
}

.shipments-stats dd {
  margin: 0;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-title-sm);
  font-variant-numeric: tabular-nums;
}

/* ‹ Apr  Jun ›: 12 px, black, with bold 22 px chevrons; a month that cannot be reached is disabled */
.shipments-pager {
  margin: 20px 0 0;
}

.shipments-pager .sonar-button {
  --_ink: var(--sonar-color-text-strong);
  --_padding: 0;
  gap: 6px;
  --_icon: 22px;
  height: 22px;
  padding-inline: 0;
  font: var(--sonar-font-weight-medium) 12px/1.2 var(--sonar-font-family-sans);
  letter-spacing: -0.2px;
}

.shipments-pager .sonar-button:disabled {
  --_ink: var(--sonar-color-text-disabled);
}

.shipments-pager .sonar-button svg {
  width: 22px;
  height: 22px;
}

/* ---------- Balance ---------- */

/* Overdue: its label, the amount in green, the tick bar and its share centered under it; then what falls due, in
   green on one line, with bold arrows; then the payment calendar's heading between two rules */
.balance-block {
  display: grid;
}

.balance-amount {
  color: var(--sonar-color-text-accent);
  font: var(--sonar-font-weight-medium) 12px/1.2 var(--sonar-font-family-sans);
  letter-spacing: -0.2px;
  font-variant-numeric: tabular-nums;
}

.balance-overdue .card-heading {
  margin-top: 5px;
}

.balance-overdue .balance-amount {
  margin: 15px 0 8px 2px;
}

.balance-share {
  margin: 6px 0 0;
  color: var(--sonar-color-text-accent);
  font: 400 12px/1.2 var(--sonar-font-family-sans);
  text-align: center;
}

/* Rules and headings below add to typography.css, which may load after this file: two classes win either way */
.card-rule.balance-rule {
  margin: 18px 0 0;
}

.balance-due-block {
  margin-top: 9px;
}

.balance-due-block .card-row {
  min-height: 24px;
}

.balance-due-amount {
  margin: 9px 0 19px;
}

.balance-due-share {
  font-weight: 400;
  letter-spacing: -0.4px;
}

.balance-due-block + .balance-rule {
  margin-top: 0;
}

/* The arrows of Balance and of the calendar's month are 24 px, bold and black; Balance's stay black whether or not
   they can move. The day arrows beside the week are 14 px, also black. A month out of reach is disabled. */
.balance-steps .sonar-icon-button,
.balance-steps .sonar-icon-button[aria-disabled='true'],
.cal-months .sonar-icon-button,
.cal-week .sonar-icon-button {
  --_ink: var(--sonar-color-text-strong);
  --_icon: 24px;
}

.cal-week .sonar-icon-button {
  --_icon: 14px;
}

.cal-months .sonar-icon-button:disabled,
.cal-week .sonar-icon-button:disabled {
  --_ink: var(--sonar-color-text-disabled);
}

.balance-steps .sonar-icon-button svg,
.cal-months .sonar-icon-button svg {
  width: 24px;
  height: 24px;
}

.cal-week .sonar-icon-button svg {
  width: 14px;
  height: 14px;
}

/* S: tighter */
.sonar-card[data-size='s'] .card-rule.balance-rule {
  margin-top: 10px;
}

/* ---------- Calendar: Deliveries and Balance's payments ---------- */

.cal {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-height: 0;
}

.cal-head {
  display: flex;
  gap: var(--sonar-space-8);
  align-items: center;
  min-height: 32px;
  margin-top: calc(-1 * var(--sonar-space-4));
}

.cal-head .card-heading {
  min-width: 7ch;
  font-size: 14px;
  letter-spacing: 0;
}

.cal-months {
  margin-left: auto;
}

/* The week: seven days visible, the chosen one in the middle under a fixed dot */
.cal-week {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  margin: var(--sonar-space-8) calc(-1 * var(--sonar-space-8)) 0;
}

/* The frame holds the needle, so it stays put while the strip scrolls under it */
.cal-frame {
  position: relative;
  min-width: 0;
}

/* The needle: one dot, always under the middle day */
.cal-frame::after {
  content: '';
  position: absolute;
  bottom: -3px;
  left: 50%;
  width: 3px;
  height: 3px;
  margin-left: -1.5px;
  border-radius: 50%;
  background: var(--sonar-color-fill-strong);
  pointer-events: none;
}

/* A native scroller, sideways only, that snaps day by day; no bar, no bounce into the card */
.cal-strip {
  container-type: inline-size;
  padding: 0 0 3px;
  overflow: auto hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  cursor: grab;
}

.cal-strip::-webkit-scrollbar {
  display: none;
}

.cal-strip:active {
  cursor: grabbing;
}

/* Three empty days' room at each end, so the first and last days can reach the middle */
.cal-track {
  display: flex;
  width: max-content;
  padding-inline: calc(100cqw * 3 / 7);
}

.cal-cell {
  flex: none;
  width: calc(100cqw / 7);
  scroll-snap-align: center;
}

.cal-day {
  position: relative;
  z-index: 0;
  display: grid;
  gap: 9px;
  justify-items: center;
  padding: var(--sonar-space-2) 0;
  border: 0;
  background: none;
  color: var(--sonar-color-text-meta);
  font: var(--sonar-type-label-md);
  font-variant-numeric: tabular-nums;
  user-select: none;
  cursor: inherit;
  -webkit-tap-highlight-color: transparent;
}

.cal-wd {
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-label-md-condensed);
  text-decoration: none;
}

.cal-dn {
  transition: color var(--sonar-motion-effects-fast-duration) linear;
}

/* The pill shows only under the pointer; the chosen day is told by its strong figure and the dot under it */
.cal-day .cal-dn::before {
  content: '';
  position: absolute;
  z-index: -1;
  bottom: 0;
  left: 50%;
  width: 32px;
  height: 22px;
  margin-left: -16px;
  border-radius: var(--sonar-shape-corner-full);
  background: var(--sonar-color-state-hover);
  opacity: 0;
  transform: scale(0.88);
  transition:
    opacity var(--sonar-motion-effects-fast-duration) linear,
    transform var(--sonar-motion-spatial-fast-duration) var(--sonar-motion-spatial-fast-easing);
}

@media (hover: hover) {
  .cal-day:hover .cal-dn {
    color: var(--sonar-color-text-strong);
  }

  .cal-day:hover .cal-dn::before {
    opacity: 1;
    transform: none;
  }
}

.cal-day:active .cal-dn::before {
  opacity: 1;
  transform: scale(0.94);
}

.cal-day[aria-pressed='true'] {
  color: var(--sonar-color-text-strong);
  font-weight: var(--sonar-font-weight-semibold);
}

.cal-day:focus-visible,
.cal-date:focus-visible {
  outline: var(--sonar-focus-width) solid var(--sonar-color-focus);
  outline-offset: calc(-1 * var(--sonar-focus-width));
  border-radius: var(--sonar-shape-corner-small);
}

/* The month: a grid from Monday; the chosen day is strong, with the dot under it */
.cal-month {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  margin: var(--sonar-space-4) 0 var(--sonar-space-4);
  text-align: center;
}

.cal-month .cal-wd {
  padding: var(--sonar-space-4) 0 var(--sonar-space-8);
}

.cal-date {
  position: relative;
  z-index: 0;
  height: 40px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-label-md);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}

.cal-date::before {
  content: '';
  position: absolute;
  z-index: -1;
  top: 50%;
  left: 50%;
  width: 32px;
  height: 32px;
  margin: -16px 0 0 -16px;
  border-radius: 50%;
  background: var(--sonar-color-state-hover);
  opacity: 0;
  transform: scale(0.88);
  transition:
    opacity var(--sonar-motion-effects-fast-duration) linear,
    transform var(--sonar-motion-spatial-fast-duration) var(--sonar-motion-spatial-fast-easing);
}

@media (hover: hover) {
  .cal-date:hover::before {
    opacity: 1;
    transform: none;
  }
}

.cal-date[data-out] {
  color: var(--sonar-color-text-disabled);
}

span.cal-date {
  display: grid;
  place-items: center;
  cursor: default;
}

.cal-date[aria-pressed='true'] {
  font: var(--sonar-type-title-sm);
  cursor: default;
}

.cal-date[aria-pressed='true']::after {
  content: '';
  position: absolute;
  bottom: 7px;
  left: 50%;
  width: 3px;
  height: 3px;
  margin-left: -1.5px;
  border-radius: 50%;
  background: var(--sonar-color-fill-strong);
}

/* The chosen day and its rows */
.cal-day-block {
  display: flex;
  flex: 0 1 auto;
  flex-direction: column;
  min-height: 0;
}

.cal-day-block:has(> .cal-empty) {
  flex: 1;
}

/* The day between two rules, in its band: the same air above and under the date, and as much again under the rule
   before the first row. Both rules stay put when the rows scroll. */
.cal-dayhead {
  box-sizing: border-box;
  flex: none;
  height: var(--card-day-band);
  margin-top: var(--sonar-space-14);
  border-top: 1px solid var(--sonar-color-divider);
  border-bottom: 1px solid var(--sonar-color-divider);
}

.cal-dayhead .card-figure {
  font-weight: var(--sonar-font-weight-semibold);
}

.cal-total {
  color: var(--sonar-color-text-strong);
  font: var(--sonar-font-weight-semibold) 14px/1.4 var(--sonar-font-family-sans);
  letter-spacing: -0.2px;
  font-variant-numeric: tabular-nums;
}

.cal-rows {
  display: grid;
  margin: 0;
}

/* One line a row, 38 px between rules: the code and its figures, the state as a dot in a 16 px column and View,
   56 px wide. The figures share the code's ink; thin bars separate them. */
.cal-rows li {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 16px 56px;
  gap: var(--sonar-space-12);
  align-items: center;
  min-height: var(--card-row-height);
  border-bottom: 1px solid var(--sonar-color-divider);
}

/* The row whose details are open stays marked */
.cal-rows li[data-open] {
  background: linear-gradient(90deg, transparent, var(--sonar-color-surface-selected) 10%, var(--sonar-color-surface-selected) 90%, transparent);
}

.cal-row-id {
  display: flex;
  align-items: center;
  min-width: 0;
  color: var(--sonar-color-text-strong);
  font: 400 12px/1.2 var(--sonar-font-family-sans);
  white-space: nowrap;
}

.cal-row-id strong {
  font-weight: 600;
}

.cal-row-meta {
  overflow: hidden;
  letter-spacing: -0.4px;
  font-variant-numeric: tabular-nums;
  text-overflow: ellipsis;
}

/* The alert mark follows the last figure */
.cal-row-id .cal-alert {
  margin-left: var(--sonar-space-12);
}

/* The state: a dot, centered a little left of its column */
.cal-rows .sonar-status {
  justify-self: center;
  translate: -6px 0;
}

/* View: a 56 × 19 pill, its label 11 px; a filled pill for a row that needs attention. The pointer and the finger
   get a 25 px target over the row's air. */
.cal-rows .sonar-button.cal-view {
  position: relative;
  width: 56px;
  min-width: 0;
  height: 19px;
  /* Even padding: the label's caps sit on the pill's middle */
  padding: 0;
  border-radius: 10px;
  font: var(--sonar-font-weight-medium) 11px/1 var(--sonar-font-family-sans);
  letter-spacing: -0.22px;
}

/* Pressed (1.1.6, restored): squarer while pressed, 10 → 6 px, as v38's .vd and the approved videos. The pill's own
   10 px radius above outranks the button's press shape, so the View button states its own. */
.cal-rows .sonar-button.cal-view:active:not(:disabled, [aria-disabled='true']) {
  border-radius: 6px;
}

.cal-rows .sonar-button.cal-view[data-variant='outlined'] {
  --_edge: var(--sonar-color-accent);
  --_ink: var(--sonar-color-text-accent);
}

.cal-rows .sonar-button.cal-view::after {
  content: '';
  position: absolute;
  inset: -3px 0;
}

.cal-empty {
  display: grid;
  flex: 1;
  gap: var(--sonar-space-4);
  place-content: center;
  justify-items: center;
  padding: var(--sonar-space-20) 0;
  border-bottom: 1px solid var(--sonar-color-divider);
  text-align: center;
}

.cal-empty svg {
  width: 28px;
  height: 28px;
  margin-bottom: var(--sonar-space-4);
  color: var(--sonar-color-text-disabled);
}

.cal-empty p {
  margin: 0;
}

/* Under the day, faded: the next day (month) or the next day with rows (week, XL). It takes no focus. */
.cal-peek {
  opacity: 0.4;
}

.cal-peek[data-view='week'] {
  flex: 1 1 0;
  min-height: 0;
  overflow: hidden;
  mask-image: linear-gradient(#000 calc(100% - 44px), transparent);
}

.cal-peek .cal-dayhead {
  margin-top: var(--sonar-space-14);
}

/* A new day comes in from the side it was reached from; today and tomorrow in Balance too */
@keyframes cal-enter {
  from {
    opacity: 0;
    transform: translateX(calc(var(--dir, 0) * 10px));
  }
}

[data-enter] {
  animation: cal-enter var(--sonar-motion-spatial-default-duration) var(--sonar-motion-spatial-default-easing) both;
  animation-delay: calc(var(--i, 0) * 24ms);
}

@media (prefers-reduced-motion: reduce) {
  [data-enter] {
    animation: none;
  }

  .cal-day .cal-dn::before,
  .cal-date::before {
    transition: none;
  }
}

/* ---------- Balance: the payment calendar, folded ---------- */

.balance-calendar-toggle {
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-sizing: border-box;
  flex: none;
  width: calc(100% + 2 * var(--sonar-space-8));
  height: var(--sonar-layout-touch-target);
  margin: 0 calc(-1 * var(--sonar-space-8));
  padding: 0 var(--sonar-space-8);
  border: 0;
  border-radius: var(--sonar-shape-corner-small);
  /* The rule under it, drawn inside the side margins so it lines up with the others */
  background: linear-gradient(var(--sonar-color-divider), var(--sonar-color-divider)) no-repeat var(--sonar-space-8) 100% / calc(100% - 2 * var(--sonar-space-8)) 1px;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-title-lg);
  letter-spacing: var(--sonar-type-title-lg-tracking);
  text-align: left;
  cursor: pointer;
}

@media (hover: hover) {
  .balance-calendar-toggle:hover {
    background-color: var(--sonar-color-state-hover);
  }
}

.balance-calendar-toggle:focus-visible {
  outline: var(--sonar-focus-width) solid var(--sonar-color-focus);
  outline-offset: calc(-1 * var(--sonar-focus-width));
}

.balance-calendar-chevron {
  width: 24px;
  height: 24px;
  transition: transform var(--sonar-motion-spatial-default-duration) var(--sonar-motion-spatial-default-easing);
}

.balance-calendar-toggle[aria-expanded='true'] .balance-calendar-chevron {
  transform: rotate(180deg);
}

.balance-calendar:not([hidden]) {
  margin-top: var(--sonar-space-12);
  animation: cal-fade var(--sonar-motion-effects-default-duration) var(--sonar-motion-effects-default-easing) both;
}

@keyframes cal-fade {
  from {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .balance-calendar-chevron {
    transition: none;
  }

  .balance-calendar:not([hidden]) {
    animation: none;
  }
}

/* ---------- Recurring orders ---------- */

/* Quick action: a 20 px chip in the accent, 10 px caps */
.recurring-quick {
  display: flex;
  align-items: center;
  width: fit-content;
  height: 20px;
  margin: -4px 0 6px;
  padding: 0 7px;
  box-sizing: border-box;
  border-radius: 3px;
  background: var(--sonar-color-accent);
  color: var(--sonar-color-on-accent);
  font: var(--sonar-font-weight-semibold) 10px/1 var(--sonar-font-family-sans);
  letter-spacing: 0.015em;
  text-transform: uppercase;
}

.recurring-card .sonar-card-header {
  align-items: end;
}

/* The summary reads as a sentence here, lower under the title; the list starts at its rule */
.recurring-card .sonar-card-summary {
  margin-top: var(--sonar-space-20);
  padding-bottom: var(--sonar-space-12);
  font: var(--sonar-type-label-md);
}

.recurring-card .sonar-card-summary-label {
  color: var(--sonar-color-text-strong);
  text-transform: none;
}

.recurring-card .sonar-card-summary-value {
  font-weight: inherit;
}

.recurring-card .sonar-card-body {
  padding-top: 0;
}

.recurring-card[data-size] .sonar-card-body {
  padding-top: 4px;
}

/* Recurring orders alone (1.0.0-alpha.24, 988©STUDIO): the bottom fade ends above Reorder rather than level with it, clear of
   the button by half the room from the button to the card's foot (22 px / 2). The list reaches 4 px past its end, so
   the fade is lifted 4 + 11 px; it keeps its 24 px. Scrolled to the end there is no bottom fade, as before. */
.recurring-card .sonar-card-scroll-area[data-scrollable]:not([data-at-end]) {
  --_fade-lift: 15px;
  mask-image: linear-gradient(#000 calc(100% - var(--_fade-bottom, 24px) - var(--_fade-lift)), transparent calc(100% - var(--_fade-lift)));
}

.recurring-card .sonar-card-scroll-area[data-scrollable]:not([data-at-end], [data-at-start]) {
  mask-image: linear-gradient(
    transparent,
    #000 var(--_fade-top, 22px),
    #000 calc(100% - var(--_fade-bottom, 24px) - var(--_fade-lift)),
    transparent calc(100% - var(--_fade-lift))
  );
}


.recurring-bolt {
  width: 24px;
  height: 24px;
  color: var(--sonar-color-accent);
}

@keyframes recurring-count {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

.recurring-count {
  display: inline-block;
  animation: recurring-count var(--sonar-motion-spatial-fast-duration) var(--sonar-motion-spatial-fast-easing) both;
}

/* Scrolled to its end, the list keeps the same distance between its last rule and the footer's button as between
   a row's last line and its rule */
.recurring-list {
  display: grid;
  margin: 0;
  padding-bottom: var(--card-list-rhythm);
}

/* One distance between anything and its rule: rule, thumbnail, rule; a suggestion under a product, then a rule */
.recurring-list > li {
  padding: var(--card-list-rhythm) 0;
  border-bottom: 1px solid var(--sonar-color-divider);
}

.recurring-list > li:has(+ .recurring-alternative-wrap[data-open]),
.recurring-list > li:has(> .recurring-banner) {
  border-bottom: 0;
}

.recurring-list > li:has(> .recurring-banner) {
  padding-top: 0;
  border-bottom: 1px solid var(--sonar-color-divider);
}

.recurring-list > li:has(+ li > .recurring-banner) {
  border-bottom: 0;
}

.recurring-product {
  display: grid;
  grid-template-columns: var(--card-thumbnail) minmax(0, 1fr);
  gap: var(--sonar-space-12);
  align-items: start;
}

/* The checkbox sits at the end of the row, the product's name and lines before it */
.recurring-product .sonar-checkbox-main {
  flex-direction: row-reverse;
  justify-content: space-between;
  min-height: 0;
}

.recurring-product .sonar-checkbox-label {
  margin-top: 3px;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-label-md);
}

.recurring-product .recurring-stock {
  margin-top: 4px;
}

.recurring-product .sonar-checkbox-description {
  display: grid;
  gap: 0;
  padding-left: 0;
}

/* A suggestion from the market: a 49 px band, its two lines beside a green bookmark with an info mark */
.recurring-banner {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  box-sizing: border-box;
  min-height: 49px;
  margin: 1px 0 0;
  padding: 4px 12px 4px 66px;
  border-radius: 16px;
  background: color-mix(in srgb, var(--sonar-color-accent-container) 60%, transparent);
  color: var(--sonar-color-text-strong);
  font: 400 12px/16px var(--sonar-font-family-sans);
  letter-spacing: -0.02em;
}

.recurring-banner a {
  color: inherit;
  font-weight: var(--sonar-font-weight-medium);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.recurring-bookmark {
  position: absolute;
  top: 0;
  left: 14px;
  display: grid;
  place-items: center;
  width: 27px;
  height: 39px;
  padding-bottom: 6px;
  box-sizing: border-box;
  background: var(--sonar-color-accent);
  color: var(--sonar-color-on-accent);
  clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 82%, 0 100%);
}

.recurring-bookmark svg {
  width: 18px;
  height: 18px;
}

.recurring-pack {
  color: var(--sonar-color-text-meta);
}

.recurring-stock {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 5px;
  align-items: center;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-label-md);
}

.recurring-stock svg {
  width: 13px;
  height: 13px;
  color: var(--sonar-color-accent);
}

.recurring-stock[data-out],
.recurring-stock[data-out] svg {
  color: var(--sonar-color-text-danger);
}

.recurring-product[data-out] .recurring-well {
  opacity: 0.55;
}

/* When it is back: gray, after a thin bar */
.recurring-stock[data-out] .recurring-eta {
  color: var(--sonar-color-text-meta);
  font-weight: 400;
}

.recurring-tag {
  margin-left: var(--sonar-space-4);
  padding: 0 var(--sonar-space-6);
  border-radius: var(--sonar-shape-corner-extra-small);
  background: var(--sonar-color-accent-tonal);
  color: var(--sonar-color-on-accent-tonal);
  font: var(--sonar-type-label-sm);
}

.recurring-well {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--card-thumbnail);
  height: var(--card-thumbnail);
  border-radius: var(--sonar-shape-corner-medium);
  background: var(--sonar-color-surface-well);
  color: var(--sonar-color-text-secondary);
}

.recurring-well svg {
  width: 30px;
  height: 30px;
}

/* The alternative unfolds under the product that ran out, and folds back faster */
.recurring-list > .recurring-alternative-wrap {
  display: grid;
  grid-template-rows: 0fr;
  padding: 0;
  border-bottom: 0;
  transition: grid-template-rows var(--sonar-motion-spatial-fast-duration) var(--sonar-motion-spatial-fast-easing);
}

.recurring-list > .recurring-alternative-wrap[data-open] {
  grid-template-rows: 1fr;
  border-bottom: 1px solid var(--sonar-color-divider);
  transition-duration: var(--sonar-motion-spatial-default-duration);
}

.recurring-alternative-clip {
  min-height: 0;
  overflow: hidden;
}

.recurring-alternative {
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr) auto;
  gap: 20px;
  align-items: center;
  min-height: 49px;
  box-sizing: border-box;
  margin-bottom: var(--sonar-space-12);
  padding: 6px 12px;
  border-radius: 16px;
  background: color-mix(in srgb, var(--sonar-color-text-strong) 6%, var(--sonar-color-surface-card));
  opacity: 0;
  transition: opacity var(--sonar-motion-effects-fast-duration) linear;
}

[data-open] > .recurring-alternative-clip > .recurring-alternative {
  opacity: 1;
  transition-delay: 60ms;
}

.sonar-button.recurring-swap {
  --_edge: var(--sonar-color-accent);
  --_ink: var(--sonar-color-text-accent);
}

.recurring-alternative p {
  margin: 0;
  color: var(--sonar-color-text-strong);
  font: var(--sonar-type-label-md);
}

.recurring-alternative-thumb {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  background: var(--sonar-color-surface-card);
  color: var(--sonar-color-text-default);
}

.recurring-alternative-thumb svg {
  width: 26px;
  height: 26px;
}

.recurring-alternative-mark {
  position: absolute;
  right: -5px;
  bottom: -5px;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--sonar-color-accent);
  box-shadow: 0 0 0 2px var(--sonar-color-surface-container);
  color: var(--sonar-color-on-accent);
}

.recurring-alternative-mark svg {
  width: 12px;
  height: 12px;
}

@media (prefers-reduced-motion: reduce) {
  .recurring-count {
    animation: none;
  }

  .recurring-list > .recurring-alternative-wrap,
  .recurring-alternative {
    transition: none;
  }
}

/* ---------- The overview ---------- */

/* The columns follow the overview's own width: one for every 320 px a card needs (with the gap), up to three. Each
   layout's rows are fixed tracks between the edges the cards start and end on (Overview.tsx writes them), so the
   cards are in their places from the first frame, before any script runs. The gap is inside the tracks, below each
   card. */
.overview-frame {
  container: overview / inline-size;
}

.overview {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: var(--rows-1);
  column-gap: var(--sonar-layout-gap);
  align-items: start;
  /* The last track ends with a gap below the lowest card */
  margin-bottom: calc(-1 * var(--sonar-layout-gap));
}

.overview-slot {
  --i: var(--i-1);

  grid-row: var(--row-1);
  grid-column: var(--col-1);
  min-width: 0;
}

@container overview (width >= 660px) {
  .overview {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: var(--rows-2);
  }

  .overview-slot {
    --i: var(--i-2);

    grid-row: var(--row-2);
    grid-column: var(--col-2);
  }
}

@container overview (width >= 1000px) {
  .overview {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    grid-template-rows: var(--rows-3);
  }

  .overview-slot {
    --i: var(--i-3);

    grid-row: var(--row-3);
    grid-column: var(--col-3);
  }
}

/* Until the entrance runs, the cards wait unseen in their places: they appear once, rising into place, instead of
   being drawn, hidden and drawn again. Without scripts, or with reduced motion, they are there from the first frame */
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
  .overview:not(.shown) .overview-slot {
    opacity: 0;
  }
}

/* ---------- Sizes, side by side in the documentation ---------- */

.card-sizes {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 400px));
  gap: var(--sonar-space-24);
  align-items: start;
}

.card-sizes figure {
  display: grid;
  gap: var(--sonar-space-8);
  margin: 0;
}

.card-sizes figcaption {
  color: var(--sonar-color-text-meta);
  font: var(--sonar-type-label-md);
  letter-spacing: var(--sonar-type-label-md-tracking);
}

/* A card reached by a link (#card-deliveries) stops clear of the top bar through the page’s scroll padding alone
   (the top bar and 16 px on phones and tablets, 24 px on desktops); a margin here would add to it */
.overview-slot {
  scroll-margin-top: 0;
}


/* ---------- Entrance: the first time the overview shows (the product's .intro) ----------
   Each card slot fades in and rises 14 px in 440 ms on the spatial default spring, 55 ms after the one before it in
   reading order; the tick bars inside draw from the left in 780 ms on the effects slow curve, 240 ms after their card.
   With reduced motion, nothing moves. */
@keyframes overview-slot-in {
  from {
    opacity: 0;
    transform: translateY(14px);
  }
}

@keyframes overview-ticks-in {
  from {
    clip-path: inset(0 100% 0 0);
  }

  to {
    clip-path: inset(0 0 0 0);
  }
}

.overview.intro .overview-slot {
  animation: overview-slot-in 440ms var(--sonar-motion-spatial-default-easing) both;
  animation-delay: calc(var(--i, 0) * 55ms);
}

.overview.intro .overview-slot .sonar-tick-bar {
  animation: overview-ticks-in 780ms var(--sonar-motion-effects-slow-easing) both;
  animation-delay: calc(var(--i, 0) * 55ms + 240ms);
}

@media (prefers-reduced-motion: reduce) {
  .overview.intro .overview-slot,
  .overview.intro .overview-slot .sonar-tick-bar {
    animation: none;
  }
}

/* 1.1.5: Release stock, the Inventory card's own action (usage rule 9), sits in the header beside the unit menu at the
   header's 24 px height, so the card's content keeps its place (24 px is the WCAG 2.2 minimum target size) */
.sonar-card .inventory-release {
  block-size: 24px;
  min-block-size: 24px;
  padding-block: 0;
}

Optional cards

Five more modules can join the overview, from New element while editing or from All modules: Invoices, Credit notes, Notifications, Latest documents and Scheduled orders. They come in one size, L (405 px), and are built like the six: a title, a list that scrolls between header and footer, and a link to their page. A new module goes to the foot of the shortest column.

Invoices · L

The latest invoices, newest first: date, number, the order it bills and the customer, each with a download button.

Invoices

Tuesday, May 12
INV-0025698
Order 04-118B | Northwind Ltd
Monday, May 11
INV-0025677
Order 04-121A | Northwind Ltd
Monday, May 11
INV-0025671
Order 04-130A | Northwind Ltd
Friday, May 8
INV-0025664
Order 04-112B | Northwind Ltd
Thursday, May 7
INV-0025642
Order 04-109C | Northwind Ltd
Wednesday, May 6
INV-0025631
Order 04-104A | Northwind Ltd
Monday, May 4
INV-0025615
Order 04-101B | Northwind Ltd
Friday, May 1
INV-0025602
Order 04-097A | Northwind Ltd
Credit notes · L

Credits issued to the account: date, number, the order and the reason, such as “Damaged pallets” or “Late delivery”.

Credit notes

Wednesday, May 13
CN-0004518
Order 04-118B | Damaged pallets
Tuesday, May 12
CN-0004502
Order 04-127C | Late delivery
Friday, May 8
CN-0004487
Order 04-112B | Price adjustment
Thursday, May 7
CN-0004463
Order 04-104A | Returned items
Tuesday, May 5
CN-0004441
Order 04-101B | Damaged pallets
Friday, May 1
CN-0004428
Order 04-097A | Price adjustment
Wednesday, April 29
CN-0004410
Order 04-090C | Returned items
Friday, April 24
CN-0004397
Order 04-086A | Late delivery
Notifications · L

Delivery alerts and updates by day and time: arrived, left its origin, and the delayed 04-127C with its red mark.

Notifications

2 new
Tuesday, May 1217:23
Delivery 04-121A has arrived at its destination.
Tuesday, May 1212:05
Delivery 04-127C has been delayed.
What to do in this case?
Monday, May 1107:00
Delivery 04-118B has left its origin.
Monday, May 1107:00
Delivery 04-130A has left its origin.
Friday, May 816:48
Delivery 04-109D has arrived at its destination.
Friday, May 808:15
Delivery 04-112B has left its origin.
Friday, May 816:40
Delivery 04-112B has arrived at its destination.
Friday, May 806:55
Delivery 04-112B has left its origin.
Thursday, May 715:10
Delivery 04-109C has arrived at its destination.
Latest documents · L

Documents grouped by order, each group with its count (“4 DOCS”) and its date; a group opens to list the files with a download button each.

Latest documents

Invoice | 000000001.pdf
Delivery note | 000000002.pdf
ISO certificate | 000000003.pdf
Packing list | 000000004.pdf
Invoice | 000000011.pdf
Delivery note | 000000012.pdf
ISO certificate | 000000013.pdf
Invoice | 000000021.pdf
Delivery note | 000000022.pdf
ISO certificate | 000000023.pdf
Packing list | 000000024.pdf
CMR waybill | 000000025.pdf
Quality report | 000000026.pdf
Pallet label | 000000027.pdf
Customs form | 000000028.pdf
Invoice | 000000031.pdf
Delivery note | 000000032.pdf
ISO certificate | 000000033.pdf
Packing list | 000000034.pdf
CMR waybill | 000000035.pdf
Invoice | 000000041.pdf
Delivery note | 000000042.pdf
ISO certificate | 000000043.pdf
Invoice | 000000051.pdf
Delivery note | 000000052.pdf
ISO certificate | 000000053.pdf
Packing list | 000000054.pdf
CMR waybill | 000000055.pdf
Quality report | 000000056.pdf
Scheduled orders · L

Upcoming orders grouped by day, each day with its count and total in pallets; a day opens to list its orders.

Scheduled orders

Order 05-201A | 120 plt
Order 05-202C | 95 plt
Order 05-204B | 195 plt
Order 05-209A | 240 plt
Order 05-210B | 60 plt
Order 05-214C | 150 plt
Order 05-215A | 80 plt
Order 05-217B | 110 plt
Order 05-218A | 45 plt
Order 05-221B | 300 plt
Order 05-226A | 175 plt
Order 05-228C | 90 plt
Order 05-231A | 130 plt
Order 05-233B | 210 plt
Order 05-234C | 60 plt

Sizes

In the overview a card is 245, 375, 405 or 535 px high and as wide as its column. Each card is designed for the sizes where its content reads well, and offers only those when people edit the overview. A larger size shows more of the same content, never different content.

Orders · S, M
S

Orders

Open total6,720 plt

May 2026

  • 1,420 pltshipped
  • 80 plton hold
  • 1,100 pltpending

June 2026

  • 1,500 pltshipped
  • 160 plton hold
  • 1,100 pltpending
M

Orders

Open total6,720 plt

May 2026

  • 1,420 pltshipped
  • 80 plton hold
  • 1,100 pltpending

June 2026

  • 1,500 pltshipped
  • 160 plton hold
  • 1,100 pltpending

New order

Create a new purchase order.

Code
tsx
<OrdersCard size="s" />
<OrdersCard size="m" />
Inventory · S, M
S

Inventory

Total2,800 plt

840 plt1,960 plt
On holdAvailable
M

Inventory

Total2,800 plt

840 plt1,960 plt
On holdAvailable
Code
tsx
<InventoryCard size="s" />
<InventoryCard size="m" />
Balance · S, M, L
S

Balance

Total balance3,900,000.00 USD

Overdue

2,230,800.00 USD

57.2%


Due today

46,800.00 USD1.2%
M

Balance

Total balance3,900,000.00 USD

Overdue

2,230,800.00 USD

57.2%


Due today

46,800.00 USD1.2%

L

Balance

Total balance3,900,000.00 USD

Overdue

2,230,800.00 USD

57.2%


Due today

46,800.00 USD1.2%

Code
tsx
<BalanceCard size="s" />
<BalanceCard size="m" />
<BalanceCard size="l" />
Daily shipments · L, XL
L

Daily shipments

Month to date4,485 plt

May 2026


XL

Daily shipments

Month to date4,485 plt

May 2026


Today
350 plt
Yesterday
370 plt
This month
4,485 plt
Returns
40 plt

Code
tsx
<ShipmentsCard size="l" />
<ShipmentsCard size="xl" />
Deliveries · M, L, XL
M

Deliveries

May 2026

Calendar view
May 12TuesdayTotal 415 plt
  • 04-118B2 items40 pltDelivered
  • 04-121A7 items150 pltDelayed
  • 04-127C1 item15 pltDelayed
  • 04-130A8 items210 pltDelivered
L

Deliveries

May 2026

Calendar view
May 12TuesdayTotal 415 plt
  • 04-118B2 items40 pltDelivered
  • 04-121A7 items150 pltDelayed
  • 04-127C1 item15 pltDelayed
  • 04-130A8 items210 pltDelivered
XL

Deliveries

May 2026

Calendar view
May 12TuesdayTotal 415 plt
  • 04-118B2 items40 pltDelivered
  • 04-121A7 items150 pltDelayed
  • 04-127C1 item15 pltDelayed
  • 04-130A8 items210 pltDelivered
Code
tsx
<DeliveriesCard size="m" />
<DeliveriesCard size="l" />
<DeliveriesCard size="xl" />
Recurring orders · M, L, XL
M

Recurring orders, quick action

Selected products1

  • Packaging CB-4030BoxStock: 380 plt
  • Take a look!new recycled-fiber boxes.

  • Packaging SF-2300RollStock: 560 plt
  • Try instead
    SF-2000410 plt

  • Packaging KP-1200RollStock: 210 plt
  • Packaging EP-5050UnitStock: 940 plt
  • Packaging CB-2010BoxStock: 640 plt
  • Packaging SF-1700RollStock: 320 plt
  • Take a look!thinner film, same hold.

  • Packaging TS-1210UnitStock: 150 plt
  • Packaging SB-1200RollStock: 480 plt
L

Recurring orders, quick action

Selected products1

  • Packaging CB-4030BoxStock: 380 plt
  • Take a look!new recycled-fiber boxes.

  • Packaging SF-2300RollStock: 560 plt
  • Try instead
    SF-2000410 plt

  • Packaging KP-1200RollStock: 210 plt
  • Packaging EP-5050UnitStock: 940 plt
  • Packaging CB-2010BoxStock: 640 plt
  • Packaging SF-1700RollStock: 320 plt
  • Take a look!thinner film, same hold.

  • Packaging TS-1210UnitStock: 150 plt
  • Packaging SB-1200RollStock: 480 plt
XL

Recurring orders, quick action

Selected products1

  • Packaging CB-4030BoxStock: 380 plt
  • Take a look!new recycled-fiber boxes.

  • Packaging SF-2300RollStock: 560 plt
  • Try instead
    SF-2000410 plt

  • Packaging KP-1200RollStock: 210 plt
  • Packaging EP-5050UnitStock: 940 plt
  • Packaging CB-2010BoxStock: 640 plt
  • Packaging SF-1700RollStock: 320 plt
  • Take a look!thinner film, same hold.

  • Packaging TS-1210UnitStock: 150 plt
  • Packaging SB-1200RollStock: 480 plt
Code
tsx
<RecurringCard size="m" />
<RecurringCard size="l" />
<RecurringCard size="xl" />

States

At rest a card is a region with its own controls. While the overview is edited it gains a dashed edge, a handle to move it, a button to remove it and a handle to resize it; it lifts while it is dragged and leaves a drop placeholder where it will land. The live version is in Editing the overview.

A card while the overview is edited, in light and dark
Light
  • Edit: a 1 px dashed edge in accent. The handles are pills of 36 × 16 px with an accent ring on the card’s surface; remove is a 22 px ring in danger outside the top end corner. Each is a button with a name: “Move Orders”, “Remove Orders from the overview”, “Resize Orders”.
  • Hover and focus fill the handle with accent and turn its icon to on-accent; remove fills with danger.
  • Lifted: the card rises to elevation 4 at 103 % and loses its dashed edge, and its move handle stays solid while it is held.
  • Drop placeholder: the slot the card leaves, as tall as the card, with a dashed accent edge on the accent container.
  • Resizing steps through the sizes the card offers, never in between. A label over the handle names the size and its height, and says when the columns end level.
The unit menu and the card link

Rules

  • A figure first. The summary says the one number someone opens the overview for; the content explains it.
  • Figures align. They use tabular numerals, and a figure keeps its unit beside it: 1,420 plt, never a bare 1,420.
  • Status in words. Rows show state with the Status component, a dot and a word, never color alone.
  • A card summarizes; the page acts. Long tasks open their panel or page; the card keeps at most one action.
  • Header and footer stay put. When content does not fit a size, it scrolls between them: a list marked with CardScroll gives way first (the deliveries of a day, the products to reorder), and the whole content scrolls only if that is not enough. A fade at the edge says there is more; a thin indicator shows while scrolling.
  • Days of another month are not shown in a card’s month grid: their cells stay empty. The week strip runs on across months, since the chosen day always sits in its middle.
  • A card that opens grows. The month view of a calendar and the payment calendar of Balance take the height they need; they fold back to the card’s size.
  • Rows open their details beside the overview. View, New order and Reorder open a side panel; the row whose details are open stays marked.

Do and don’t

DoA figure keeps its unit beside it, in tabular numerals.
Don’tA bare figure: pallets, kilograms or items?
DoDays of another month are left empty in a month grid.
Don’tDays of the months around it, grayed out: they read as days with nothing on them.

Accessibility

Keyboard

Key What it does
Tab Moves through the card’s controls in reading order: unit menu, content, footer.
Unit menu Opens with Enter, Space or Down arrow, like any menu.
Up and Down arrows, Page Up and Page Down On content that scrolls, once it has focus: scroll it. It takes focus only while it scrolls.
Left and Right arrows On the chosen day of a calendar (Deliveries, Payment calendar): the previous or next day. In the month, Up and Down move a week.

Roles and attributes

Element or attribute What it gives
<section aria-labelledby> Each card is a region named by its title, so the overview can be read card by card. Where the same card shows twice, label names each copy: “Orders, size S”.
Unit menu Named with what it shows and what that means: “Unit: plt, Pallets”, so voice control can reach it by its visible text. Its choices are a group of radio items.
Summary bar The bar between label and value is drawn, so it is never read aloud.
role="group" while scrolling Content that scrolls is a group named by its list (“Deliveries of May 12”) or by the card’s title, so focus arriving on it says what it is. The fade and the indicator are drawn and hidden.

Notes

  • Buttons repeated in rows carry names that say what they act on: “View delivery 04-127C”, not “View” six times.
  • What changes when a step button is used is announced: the day of a delivery list, the due date in Balance, the month in Daily shipments.
  • The faded next day in Deliveries (XL, and the month view) is a preview: it is hidden from assistive technology and takes no focus.
  • In the week strip only the chosen day is in the tab order; the arrow keys move the day and focus follows it. Each day is named in full: “Saturday, May 16, no deliveries”.
  • Where the same card shows in several sizes, as on this page, each copy is named with its size, so no two regions share a name.
  • See Charts for the charts inside the cards.

Built with

The cards are built from Card, Card link, Card scroll and Unit menu, with Tick bar, Ring chart, Trend chart and Chart legend. Each page has its API.