Concentric radius

Nested rounded corners that share a center. Give a child rounded="concentric" and its radius follows its rounded parent.

Nested rounded corners only look right when they share a center. Give a card a 24px radius and 12px of padding, and the image inside needs a 12px radius, not 24px. The concentric preset does that math for you.

Add rounded="concentric" to the child. It reads the radius, padding and border from the style props you already wrote on the parent, subtracts in CSS, and keeps working through every breakpoint, state and nesting level.

36px · 36px · 36px
36px · 23px · 10px

Same markup on both sides, three levels with rounded="36px" p="3" borderWidth="1px". The left copies the parent's radius onto every child, the right uses rounded="concentric".

How it works

Two rounded shapes look concentric when their corners share a center point. That happens when the inner radius equals the outer radius minus the space between the two edges.

inner radius = outer radius − gap

The gap is the parent's padding and border width, plus a padded wrapper in between and the child's own margin. For an absolutely positioned child it is the inset. rounded="concentric" reads all of it from the style props you already wrote, does the subtraction in CSS, and publishes its own radius so the next level down can do the same.

When the gap is bigger than the radius, the corner goes square instead of negative. A rounded="full" parent stays a pill all the way down.

Installation

Add the preset to your project.

pnpm add -D @isbatak/panda-concentric-preset

Add it to presets in your Panda config, next to the design system. There is no JavaScript at runtime.

import { defineConfig } from "@pandacss/dev"

export default defineConfig({
  designSystem: "@isbatak/panda-ds",
  presets: ["@isbatak/panda-concentric-preset"],
})

Then add one prop to any child of a rounded element.

<Box rounded="3xl" borderWidth="1px" p="3">
  <Box rounded="concentric" /> {/* 24 − (12 + 1) = 11px */}
</Box>

24px − (12px + 1px) = 11px

Playground

Every radius below comes from the preset. The root takes its radius, padding and border from CSS variables, and each inner level only adds rounded="concentric". The table reads the result back with getComputedStyle, so it shows what the browser rendered, next to the formula it should match.

64px
20px
20px
0px
3
LevelComputedExpectedFormula
L1…64pxset on the root
L2…44px64 − 20 = 44
L3…24px44 − 20 = 24
L4…4px24 − 20 = 4

The same settings as style props:

<Box rounded="64px" p="5">
  <Box rounded="concentric" p="5">
    <Box rounded="concentric" p="5">
      <Box rounded="concentric" p="5">
        …
      </Box>
    </Box>
  </Box>
</Box>

In real components

Each example keeps the props you would already write on the parent. Only the child changes, from a copied radius to rounded="concentric". Change any of the parent's props, at any breakpoint, and the child follows.

Card

The image uses roundedTop="concentric": 28px minus 6px padding and a 1px border is 21px.

Field notes · 6 min read

A weekend above the tree line

Notes, routes and a packing list from three days on the ridge.

Dialog

The buttons sit in a plain grid inside a 24px dialog with 8px of padding, so they get 16px.

Delete this project?

Its 14 deployments and their logs go with it. This can't be undone.

Segmented control

A SegmentGroup with rounded="20px" p="1.5" on the root. Its indicator is absolutely positioned 6px inside the track, so it gets 14px wherever it moves. The root's radius normally comes from the recipe, so the rounded prop is what makes it the rounded parent.

The button sits inside a 20px field with 6px of padding and a 1px border, so it gets 13px.

Pricing

Two levels. The card is 36px with 8px of padding and a 1px border, so the panel gets 27px. The panel has 16px of padding, so its button gets 11px.

Pro

$24 / month

For teams shipping every day.

  • Unlimited projects
  • Preview deployments
  • Priority support

Edge to edge

A negative margin makes the radius larger, for media that bleeds to the card's edge.

The negative margin cancels the padding, so the media keeps the card's full 24px.

Inset ring

An absolute or fixed child is measured by its inset plus the parent's border width.

Selected plan

The ring is inset 4px inside a 1px border: 16 − 1 − 4 = 11px.

API

Every radius prop accepts "concentric", in the same shapes as the other radius values.

PropEffect
rounded="concentric"All four corners follow the nearest rounded ancestor: its radius minus the gap between the edges.
roundedTop, roundedRight, roundedBottom, roundedLeftOne side only. The other corners keep whatever radius they already have.
roundedTopLeft, roundedTopRight, roundedBottomRight, roundedBottomLeftOne corner only.
roundedStart, roundedEnd, roundedStartStart, roundedStartEnd, …Logical sides and corners. They flip with the nearest dir attribute.
concentricGap="3"An explicit gap that replaces the measured one, for spacing the preset can't read.
concentricEllipticalKeeps one radius per axis when the horizontal and vertical gaps differ.
concentricSquircleSets corner-shape: squircle and scales the gap to match, where the browser supports it.
concentricFloor="sm"A minimum radius for every concentric element inside.

Nothing new is needed on the parent: rounded, p, borderWidth, border, m and inset props are read as they are. Tokens, raw values and CSS variables all work, and so do conditions like md and _hover.

The math is r = max(floor, R − min(gap x, gap y)). The gap is the parent's padding and border width, plus the padding and border of a wrapper in between and the child's own margin. For absolute and fixed children it is the inset plus the parent's border width.

Set a floor

Set concentricFloor to stop small radii from collapsing to a sharp corner. It sets --concentric-floor, which inherits like any custom property, so you can set it once on the page or per element.

<Box concentricFloor="sm" rounded="lg" p="4">
  <Box rounded="concentric" /> {/* 4px instead of 0 */}
</Box>
8 − 16 → 0px
floor → 4px

Limitations

  • Only the nearest wrapper's padding and border count on each side, so a second padded wrapper is skipped, and so are margins on wrappers. Give the outer wrapper rounded="concentric", as shown below.
  • Only style props are read. Padding from config recipes, inline styles or global CSS needs concentricGap. A recipe's root is not a rounded ancestor either: add a rounded prop to it.
  • Percentage radii are not supported. Percentage insets fall back to the parent's padding, and auto margins count as 0.
  • Absolute and fixed elements are measured from the nearest rounded ancestor, so that ancestor should be their containing block.
  • Direction comes from dir attributes, including dir="auto" at any depth in browsers with @scope. Older browsers read up to three nested levels.
  • Nine stacked levels are exact. Every element with a radius prop counts, and deeper ones reuse the last level.

The first two have a one-prop fix. A wrapper in the chain passes its padding on, however many wrappers sit inside it. Without a background or border, its own radius is invisible.

plain wrapper → 19px
wrapper in the chain → 11px

Only the inner wrapper's 4px reaches the items on the left. With the outer wrapper in the chain they get 24 − 1 − 8 − 4 = 11px.

Padding the preset can't read needs an explicit gap. concentricGap="3" subtracts 12px wherever the padding comes from.

measured → 24px
concentricGap="3" → 12px

FAQ