# Shape & radius

> 24px radius. 24px inset. That pairing is the signature of the system, and everything else derives from it.

Source: https://lopifi.com/docs/foundations/02-shape-and-radius
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Shape & radius

**24px radius. 24px inset.** That pairing is the signature of the system, and everything else derives from it.

---

## The scale

Radii are 8px multiples, because the nesting formula requires them to be (below).

```
--radius-none: 0px      /* rule lines, full-bleed dividers, table cells */
--radius-xs:   4px      /* badges, tags, checkbox */
--radius-sm:   8px      /* inputs, small buttons, list-row hover */
--radius-md:   12px     /* medium controls, inner panels */
--radius-lg:   16px     /* nested cards, sheets on mobile */
--radius-xl:   24px     /* CARD DEFAULT · dialogs · panels */
--radius-2xl:  32px     /* hero surfaces, marketing */
--radius-full: 9999px   /* avatars, pills, presence dots */
```

`--radius-xl: 24px` is the base. Every other radius in a composition should be *derived* from it, not chosen.

---

## The nesting formula

> **`r_inner = r_outer − gap`**

Where `gap` is the distance between the outer border and the inner element's border. This is not a stylistic preference — it is the geometry that makes two concentric rounded rectangles concentric. Get it wrong and the corners visibly diverge, which reads as sloppy even to people who can't name why.

### Worked examples

| Outer | Gap (inset) | Inner radius | Where |
|---|---|---|---|
| 24 | 24 | **0** | An element flush against the full card inset is **square**. Correct and intentional — this is why full-bleed table rows and dividers inside a card have no radius. |
| 24 | 8 | 16 | A nested panel inset 8px from the card edge |
| 24 | 12 | 12 | An inner card at 12px inset |
| 24 | 16 | 8 | An input inset 16px |
| 16 | 8 | 8 | A row inside a nested card |
| 32 | 8 | 24 | A card inside a hero surface |

### The consequence people miss
**24 outer + 24 inset = 0 inner.** An element that fills the entire content box of a 24px-radius card must have square corners. This is why:

- Table rows inside a card are square, and their hover background is square with the *card's* corner clipped by `overflow-hidden` on the card.
- A full-bleed chart at the bottom of a card inherits the card's bottom corners via `overflow-hidden`, not its own radius.
- A list-row hover background that is inset 8px from the card edge gets `radius-lg` (16), not `radius-sm`.

### When you can't satisfy the formula
If the required inner radius comes out negative, the inner element must be square (`0`) and you should question the composition — it usually means the inner element should be full-bleed rather than inset.

---

## Squircle vs. circular corners

At 24px, the difference between a true circular arc and a superellipse (`squircle`) is visible. Apple uses continuous curvature; CSS `border-radius` does not.

**Decision: circular arcs.** We ship standard `border-radius`.

Rationale: squircles require either an SVG mask, `corner-shape: squircle` (Chrome 139+, not yet baseline as of this writing), or a `paint()` worklet. Each breaks something — mask kills subpixel text antialiasing on the clipped edge, the worklet doesn't run in Safari, and `corner-shape` isn't universally available. A perfect circular corner beats a broken squircle.

**Revisit gate:** when `corner-shape: squircle` reaches Baseline Widely Available, adopt it *progressively*:
```css
.card { border-radius: 24px; }
@supports (corner-shape: squircle) {
  .card { corner-shape: squircle; }
}
```
No layout changes required. Add it to [`roadmap.md`](../roadmap.md) as a deferred item.

---

## Radius by component class

| Class | Radius | Note |
|---|---|---|
| Card, panel, dialog | 24 | The default |
| Sheet / drawer (mobile) | 16 top corners only | Bottom corners are off-screen; rounding them costs nothing and gains nothing |
| Popover, dropdown, menu | 12 | Smaller because they're smaller — a 24px radius on a 160px-wide menu eats the corners |
| Tooltip | 8 | |
| Input, select, textarea | 8 | |
| Button (md, 40px) | 8 | |
| Button (lg, 48px) | 12 | Scales with height |
| Button (sm/xs) | 8 / 4 | |
| Pill / badge / status | full | |
| Avatar | full | Except **bank and merchant logos**, which are `8` — see [imagery](08-imagery-and-isometric.md#logo-treatment) |
| Payment card visual | 5.9% of width | ISO 7810 physical spec, ~16px at 272px wide |
| Table row hover | 0 (full-bleed) or 8 (inset) | Per the nesting formula |
| Chart bar | 4 top corners | Only when bar width > 12px; below that, square |
| Skeleton | matches the element it replaces | A rounded skeleton for a square element is a tell |

---

## Corner logic

Rules that prevent the most common shape mistakes.

1. **Corners that touch a viewport edge are square.** A sheet at the bottom of the screen, a sidebar flush left, a full-width mobile card — round only the corners that are actually visible. Rounding an off-screen corner is a wasted 24px of content width.

2. **Adjacent segmented controls share a radius envelope.** In a button group, the first child rounds left, the last rounds right, the middles are square. Never round every child.

3. **Clip, don't double-round.** When content must follow a card's corner (an image, a chart, a colored bar), put `overflow: hidden` on the card and give the child `radius: 0`. Two separately-rounded elements at the same corner never align perfectly.

4. **`overflow: hidden` has a cost.** It creates a new stacking/clipping context, so a shadow, focus ring, or tooltip inside a clipped card will be cut off. If you need both clipping *and* an escaping child, clip a dedicated inner wrapper, not the card.

5. **Radius does not scale with density.** A `compact` table still lives in a 24px card. Density changes spacing and type, not shape — otherwise the product's identity changes when a user flips a setting.

6. **Focus rings sit outside the radius.** `outline: 2px solid var(--focus); outline-offset: 2px;` follows `border-radius` automatically and never gets clipped. Never build a focus ring with `box-shadow` on a clipped element.

---

## Borders and shape

Radius and border are one system. A 1px border on a 24px radius must be drawn *on* the shape, not inside it. Practical consequences:

- Use `border` (which follows radius) over `box-shadow: 0 0 0 1px` (which also follows radius but stacks differently with other shadows). Prefer `border` for resting state, `box-shadow` ring for transient/focus states.
- On a nested element, `border` adds 1px to the outside; the nesting formula uses the *border box*. Two 24px cards separated by an 8px gap where both have borders: inner radius is still `24 − 8 = 16`.

Full elevation treatment in [elevation](04-elevation-borders-shadows.md).
