Renders as an article element for self-contained content.
Renders as a main element for primary page content.
A foundational background component for creating theme-aware panels with support for light and dark modes. The Panel component provides three emphasis levels (primary, secondary, tertiary), semantic tone colors, interactive states, and extensive customization options for building cards, sections, and container elements.
Panel supports five surface steps (surface.peak through surface.ground) plus transparent. emphasis picks the ramp they come from (the tone accent, the tone tint or neutral), and background picks the step on it: peak sits closest to the page and ground furthest from it. Use the steps to build visual hierarchy within your layout. The translucent prop frosts the chosen step at /35 over whatever sits behind the panel.
Lightest surface — the default and primary background for the application.
Slightly darker surface for subtle layering.
Mid-tone surface for visual prominence.
Darker surface for nested containers or secondary areas.
Darkest surface for recessed areas, wells, or secondary zones.
No background — inherits from its parent container.
Frosted Glass Effect
This translucent panel has a frosted glass effect with backdrop blur. Notice how the colorful shapes behind it are visible but blurred.
---
// GENERATED from packages/demos/panel/variants.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
})}
>
<div>
<h3>Surface 1</h3>
<Panel background="surface.peak" padding="lg">
<p>
Lightest surface — the default and primary background for the
application.
</p>
</Panel>
</div>
<div>
<h3>Surface 2</h3>
<Panel background="surface.hill" padding="lg">
<p>Slightly darker surface for subtle layering.</p>
</Panel>
</div>
<div>
<h3>Surface 3</h3>
<Panel background="surface.base" padding="lg">
<p>Mid-tone surface for visual prominence.</p>
</Panel>
</div>
<div>
<h3>Surface 4</h3>
<Panel background="surface.valley" padding="lg">
<p>Darker surface for nested containers or secondary areas.</p>
</Panel>
</div>
<div>
<h3>Surface 5</h3>
<Panel background="surface.ground" padding="lg">
<p>Darkest surface for recessed areas, wells, or secondary zones.</p>
</Panel>
</div>
<div>
<h3>Transparent</h3>
<Panel background="transparent" padding="lg" border="default">
<p>No background — inherits from its parent container.</p>
</Panel>
</div>
<div>
<h3>Translucent</h3>
<div
class={css({
position: "relative",
padding: "md",
borderRadius: "md",
overflow: "hidden",
background:
"linear-gradient(135deg, token(colors.primary) 0%, token(colors.primary.surface.peak) 50%, token(colors.primary) 100%)",
})}
>
<svg
class={css({
position: "absolute",
top: "0",
left: "0",
width: "full",
height: "full",
})}
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="20%"
cy="30%"
r="60"
fill="var(--colors-primary-surface)"
></circle>
<circle
cx="80%"
cy="60%"
r="80"
fill="var(--colors-primary-border-muted)"
></circle>
<rect
x="40%"
y="10%"
width="100"
height="100"
fill="var(--colors-primary-border)"
rx="15"
></rect>
<polygon
points="70,20 90,60 50,60"
fill="var(--colors-primary)"
transform="translate(200, 80)"
></polygon>
<circle
cx="60%"
cy="80%"
r="50"
fill="var(--colors-primary-hover)"
></circle>
<rect
x="10%"
y="70%"
width="80"
height="80"
fill="var(--colors-primary-active)"
rx="20"
></rect>
</svg>
<Panel
translucent
padding="md"
radius="xs"
class={css({ position: "relative", zIndex: "1" })}
>
<p>
<strong>Frosted Glass Effect</strong>
</p>
<p>
This translucent panel has a frosted glass effect with backdrop blur.
Notice how the colorful shapes behind it are visible but blurred.
</p>
</Panel>
</div>
</div>
</div>Use semantic tone colors to convey meaning and context. Panels default to a neutral (gray) color palette. All five tone colors (neutral, primary, success, warning, danger) work with all three emphasis levels. A color family (teal, cyan, violet, pink and the tinted neutrals slate, stone, sage, mauve) is accepted too: it colors without implying a status — use it for labels, people and categories, and keep the semantic tones for status.
Emphasis Secondary
colorPalette.surface — neutral tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of tone.
Emphasis Primary
Full-color (500) background with contrast text.
Emphasis Secondary
colorPalette.surface — palette-aware tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of tone.
Emphasis Primary
Full-color primary (500) background with contrast text.
Emphasis Secondary
colorPalette.surface — palette-aware tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of tone.
Emphasis Primary
Full-color success (500) background with contrast text.
Emphasis Secondary
colorPalette.surface — palette-aware tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of tone.
Emphasis Primary
Full-color warning (500) background with contrast text.
Emphasis Secondary
colorPalette.surface — palette-aware tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of tone.
Emphasis Primary
Full-color danger (500) background with contrast text.
Teal
A color family: no status meaning, same surface model.
Cyan
A color family: no status meaning, same surface model.
Violet
A color family: no status meaning, same surface model.
Pink
A color family: no status meaning, same surface model.
Slate
A color family: no status meaning, same surface model.
Stone
A color family: no status meaning, same surface model.
Sage
A color family: no status meaning, same surface model.
Mauve
A color family: no status meaning, same surface model.
---
// GENERATED from packages/demos/panel/tone.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
width: "100%",
})}
>
<div>
<h3>Neutral</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel tone="neutral" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Emphasis Secondary</strong>
</p>
<p>colorPalette.surface — neutral tinted background.</p>
</Panel>
<Panel tone="neutral" emphasis="tertiary" padding="lg" border="default">
<p>
<strong>Emphasis Tertiary</strong>
</p>
<p>neutral.surface — always neutral regardless of tone.</p>
</Panel>
<Panel tone="neutral" emphasis="primary" padding="lg">
<p>
<strong>Emphasis Primary</strong>
</p>
<p>Full-color (500) background with contrast text.</p>
</Panel>
</div>
</div>
<div>
<h3>Primary</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel tone="primary" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Emphasis Secondary</strong>
</p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel tone="primary" emphasis="tertiary" padding="lg" border="default">
<p>
<strong>Emphasis Tertiary</strong>
</p>
<p>neutral.surface — always neutral regardless of tone.</p>
</Panel>
<Panel tone="primary" emphasis="primary" padding="lg">
<p>
<strong>Emphasis Primary</strong>
</p>
<p>Full-color primary (500) background with contrast text.</p>
</Panel>
</div>
</div>
<div>
<h3>Success</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel tone="success" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Emphasis Secondary</strong>
</p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel tone="success" emphasis="tertiary" padding="lg" border="default">
<p>
<strong>Emphasis Tertiary</strong>
</p>
<p>neutral.surface — always neutral regardless of tone.</p>
</Panel>
<Panel tone="success" emphasis="primary" padding="lg">
<p>
<strong>Emphasis Primary</strong>
</p>
<p>Full-color success (500) background with contrast text.</p>
</Panel>
</div>
</div>
<div>
<h3>Warning</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel tone="warning" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Emphasis Secondary</strong>
</p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel tone="warning" emphasis="tertiary" padding="lg" border="default">
<p>
<strong>Emphasis Tertiary</strong>
</p>
<p>neutral.surface — always neutral regardless of tone.</p>
</Panel>
<Panel tone="warning" emphasis="primary" padding="lg">
<p>
<strong>Emphasis Primary</strong>
</p>
<p>Full-color warning (500) background with contrast text.</p>
</Panel>
</div>
</div>
<div>
<h3>Danger</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel tone="danger" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Emphasis Secondary</strong>
</p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel tone="danger" emphasis="tertiary" padding="lg" border="default">
<p>
<strong>Emphasis Tertiary</strong>
</p>
<p>neutral.surface — always neutral regardless of tone.</p>
</Panel>
<Panel tone="danger" emphasis="primary" padding="lg">
<p>
<strong>Emphasis Primary</strong>
</p>
<p>Full-color danger (500) background with contrast text.</p>
</Panel>
</div>
</div>
<div>
<h3>Color families</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel tone="teal" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Teal</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="cyan" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Cyan</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="violet" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Violet</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="pink" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Pink</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="slate" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Slate</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="stone" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Stone</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="sage" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Sage</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
<Panel tone="mauve" emphasis="secondary" padding="lg" border="default">
<p>
<strong>Mauve</strong>
</p>
<p>A color family: no status meaning, same surface model.</p>
</Panel>
</div>
</div>
</div>Three emphasis levels control how the tone color is applied. tertiary (the default) always sits on the neutral surface ramp with tone-colored border and accent text — the most subtle option. secondary uses palette-aware surface tints. primary fills with the tone color’s accent.surface.* ramp (anchored on shade 500) and uses contrast text; the background prop still takes surface.* values, each picking a different step within the accent ramp. The border prop still controls outline visibility on tertiary; set border="none" to opt out.
Always sits on the neutral surface ramp, with the tone color applied to the border and accent text. The most subtle emphasis and the new default.
Primary tone
Neutral surface, primary border + accent text.
Success tone
Neutral surface, success border + accent text.
Warning (transparent)
No fill, warning border + text.
Danger (border=none)
Border opt-out — only the accent text remains.
Uses colorPalette.surface.* — a palette-aware tinted background that responds to the active tone color.
surface.peak
Default layer.
surface.base
Mid-tone palette surface.
surface.ground
Deepest palette surface.
transparent
No background, palette text.
Fills with the tone color's accent.surface.* ramp (anchored on shade 500) and uses contrast text. The background prop still takes surface.* values — each value picks a different step within the accent ramp.
Default (surface.peak → shade 500)
The brand fill.
surface.hill
One step deeper than the brand.
surface.base
Two steps deeper.
surface.ground
The deepest accent step.
Each tone color generates its own accent.surface ramp around its shade 500.
Primary
Success
Warning
Danger
---
// GENERATED from packages/demos/panel/emphasis.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div class={stack({ gap: "2xl", direction: "column", width: "100%" })}>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Tertiary (default)</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Always sits on the neutral surface ramp, with the tone color applied to
the border and accent text. The most subtle emphasis and the new default.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel emphasis="tertiary" tone="primary" padding="lg">
<p>
<strong>Primary tone</strong>
</p>
<p class={css({ fontSize: "sm" })}>
Neutral surface, primary border + accent text.
</p>
</Panel>
<Panel emphasis="tertiary" tone="success" padding="lg">
<p>
<strong>Success tone</strong>
</p>
<p class={css({ fontSize: "sm" })}>
Neutral surface, success border + accent text.
</p>
</Panel>
<Panel
emphasis="tertiary"
tone="warning"
background="transparent"
padding="lg"
>
<p>
<strong>Warning (transparent)</strong>
</p>
<p class={css({ fontSize: "sm" })}>No fill, warning border + text.</p>
</Panel>
<Panel emphasis="tertiary" tone="danger" border="none" padding="lg">
<p>
<strong>Danger (border=none)</strong>
</p>
<p class={css({ fontSize: "sm" })}>
Border opt-out — only the accent text remains.
</p>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Secondary</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Uses <code>colorPalette.surface.*</code> — a palette-aware tinted
background that responds to the active tone color.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel emphasis="secondary" tone="primary" padding="lg" border="default">
<p>
<strong>surface.peak</strong>
</p>
<p class={css({ fontSize: "sm" })}>Default layer.</p>
</Panel>
<Panel
emphasis="secondary"
tone="primary"
background="surface.base"
padding="lg"
border="default"
>
<p>
<strong>surface.base</strong>
</p>
<p class={css({ fontSize: "sm" })}>Mid-tone palette surface.</p>
</Panel>
<Panel
emphasis="secondary"
tone="primary"
background="surface.ground"
padding="lg"
border="default"
>
<p>
<strong>surface.ground</strong>
</p>
<p class={css({ fontSize: "sm" })}>Deepest palette surface.</p>
</Panel>
<Panel
emphasis="secondary"
tone="primary"
background="transparent"
padding="lg"
border="default"
>
<p>
<strong>transparent</strong>
</p>
<p class={css({ fontSize: "sm" })}>No background, palette text.</p>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Primary</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Fills with the tone color's <code>accent.surface.*</code> ramp (anchored
on shade 500) and uses contrast text. The <code>background</code> prop
still takes <code>surface.*</code> values — each value picks a different
step within the accent ramp.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel emphasis="primary" tone="primary" padding="lg">
<p>
<strong>Default (surface.peak → shade 500)</strong>
</p>
<p class={css({ fontSize: "sm" })}>The brand fill.</p>
</Panel>
<Panel
emphasis="primary"
tone="primary"
background="surface.hill"
padding="lg"
>
<p>
<strong>surface.hill</strong>
</p>
<p class={css({ fontSize: "sm" })}>One step deeper than the brand.</p>
</Panel>
<Panel
emphasis="primary"
tone="primary"
background="surface.base"
padding="lg"
>
<p>
<strong>surface.base</strong>
</p>
<p class={css({ fontSize: "sm" })}>Two steps deeper.</p>
</Panel>
<Panel
emphasis="primary"
tone="primary"
background="surface.ground"
padding="lg"
>
<p>
<strong>surface.ground</strong>
</p>
<p class={css({ fontSize: "sm" })}>The deepest accent step.</p>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Primary across tone colors</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Each tone color generates its own accent.surface ramp around its shade
500.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel emphasis="primary" tone="primary" padding="lg">
<p>
<strong>Primary</strong>
</p>
</Panel>
<Panel emphasis="primary" tone="success" padding="lg">
<p>
<strong>Success</strong>
</p>
</Panel>
<Panel emphasis="primary" tone="warning" padding="lg">
<p>
<strong>Warning</strong>
</p>
</Panel>
<Panel emphasis="primary" tone="danger" padding="lg">
<p>
<strong>Danger</strong>
</p>
</Panel>
</div>
</div>
</div>Choose between different border styles to control the visual weight of panel borders. Use border="none" for no border, border="default" for a standard border, border="bold" for stronger emphasis using a bolder color, or border="muted" for subtle visual separation. Borders use box-shadow so they are layout-neutral — they do not affect element dimensions and compose automatically with the shadow prop.
No Border
Use border="none" for no border.
Default Border
Standard border using the neutral border token.
Bold Border
Stronger border weight for greater visual emphasis.
Muted Border
Lighter border for subtle visual separation.
Primary with Default Border
Success with Default Border
Warning with Bold Border
Danger with Muted Border
---
// GENERATED from packages/demos/panel/border.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
width: "full",
})}
>
<div>
<h3>None</h3>
<Panel background="surface.peak" padding="lg" border="none">
<p>
<strong>No Border</strong>
</p>
<p>
Use <code>border="none"</code> for no border.
</p>
</Panel>
</div>
<div>
<h3>Default</h3>
<Panel background="surface.peak" padding="lg" border="default">
<p>
<strong>Default Border</strong>
</p>
<p>Standard border using the neutral border token.</p>
</Panel>
</div>
<div>
<h3>Bold</h3>
<Panel background="surface.peak" padding="lg" border="bold">
<p>
<strong>Bold Border</strong>
</p>
<p>Stronger border weight for greater visual emphasis.</p>
</Panel>
</div>
<div>
<h3>Muted</h3>
<Panel background="surface.peak" padding="lg" border="muted">
<p>
<strong>Muted Border</strong>
</p>
<p>Lighter border for subtle visual separation.</p>
</Panel>
</div>
<div>
<h3>Border Styles with Tone Colors</h3>
<div class={stack({ gap: "md", direction: "column" })}>
<Panel tone="primary" padding="md" border="default">
<p>
<strong>Primary with Default Border</strong>
</p>
</Panel>
<Panel tone="success" padding="md" border="default">
<p>
<strong>Success with Default Border</strong>
</p>
</Panel>
<Panel tone="warning" padding="md" border="bold">
<p>
<strong>Warning with Bold Border</strong>
</p>
</Panel>
<Panel tone="danger" padding="md" border="muted">
<p>
<strong>Danger with Muted Border</strong>
</p>
</Panel>
</div>
</div>
</div>Apply drop shadows to any panel regardless of background. Shadows compose with borders via box-shadow, so both can be used together without layout side effects.
Subtle shadow for minimal depth.
Small shadow for light elevation.
Medium shadow for moderate elevation.
Large shadow for prominent elevation.
Extra large shadow for maximum elevation.
Shadow and border compose together via box-shadow.
---
// GENERATED from packages/demos/panel/shadow.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
})}
>
<div>
<h3>Extra Small</h3>
<Panel padding="lg" shadow="xs">
<p>Subtle shadow for minimal depth.</p>
</Panel>
</div>
<div>
<h3>Small</h3>
<Panel padding="lg" shadow="sm">
<p>Small shadow for light elevation.</p>
</Panel>
</div>
<div>
<h3>Medium</h3>
<Panel padding="lg" shadow="md">
<p>Medium shadow for moderate elevation.</p>
</Panel>
</div>
<div>
<h3>Large</h3>
<Panel padding="lg" shadow="lg">
<p>Large shadow for prominent elevation.</p>
</Panel>
</div>
<div>
<h3>Extra Large</h3>
<Panel padding="lg" shadow="xl">
<p>Extra large shadow for maximum elevation.</p>
</Panel>
</div>
<div>
<h3>Shadow + Border</h3>
<Panel padding="lg" shadow="md" border="default">
<p>Shadow and border compose together via box-shadow.</p>
</Panel>
</div>
</div>Enable interactive hover states for clickable panels using the interactive prop. The hover state always steps one position deeper within the current emphasis’s surface ramp (hover = +1, active = +2). Tertiary steps within neutral.surface.*, secondary within colorPalette.surface.*, and primary within colorPalette.accent.surface.*. When the resting background is already at the deep end of the ramp, the stepping reverses toward soft so the visual delta stays consistent.
Set interactive to enable hover + active states. The panel gets a pointer cursor and steps within its surface ramp on hover (+1 step toward deep) and active (+2 steps). Reverses at the high end of the ramp.
Click me!
Clicked 0 times.
Hover each panel — the hover target is always one step deeper in the ramp, regardless of where the resting background sits.
surface.peak → hover: step.1
surface.hill → hover: step.2
surface.base → hover: step.3
surface.valley → hover: step.2 (reversed)
surface.ground → hover: step.3 (reversed)
transparent → hover: step.1
Each emphasis steps within its own concrete ramp. Tertiary uses the neutral surface ramp, secondary uses the colorPalette surface ramp, and primary uses the colorPalette accent surface ramp (anchored on shade 500).
Tertiary
Steps within neutral.surface.*.
Secondary
Steps within colorPalette.surface.*.
Primary
Steps within colorPalette.accent.surface.*.
Translucent + interactive — backdrop blur with hover tone.
Frosted glass with hover
Hover to see the interactive state on the blurred panel.
---
// GENERATED from packages/demos/panel/interactive.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const clickCount = 0;
---
<div
class={stack({ gap: "2xl", direction: "column", width: "full" })}
data-ir-root="panel/interactive"
>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Basic</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Set <code>interactive</code> to enable hover + active states. The panel
gets a <code>pointer</code> cursor and steps within its surface ramp on
hover (+1 step toward deep) and active (+2 steps). Reverses at the high
end of the ramp.
</p>
<Panel background="surface.peak" padding="lg" interactive data-ir="1">
<p>
<strong>Click me!</strong>
</p>
<p>
Clicked <!--ir:2-->
{clickCount}
<!--/ir--> times.
</p>
</Panel>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Stepping adapts to the background</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Hover each panel — the hover target is always one step deeper in the ramp,
regardless of where the resting background sits.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel background="surface.peak" padding="lg" interactive>
<p>
<strong>surface.peak</strong> → hover: step.1
</p>
</Panel>
<Panel background="surface.hill" padding="lg" interactive>
<p>
<strong>surface.hill</strong> → hover: step.2
</p>
</Panel>
<Panel background="surface.base" padding="lg" interactive>
<p>
<strong>surface.base</strong> → hover: step.3
</p>
</Panel>
<Panel background="surface.valley" padding="lg" interactive>
<p>
<strong>surface.valley</strong> → hover: step.2 (reversed)
</p>
</Panel>
<Panel background="surface.ground" padding="lg" interactive>
<p>
<strong>surface.ground</strong> → hover: step.3 (reversed)
</p>
</Panel>
<Panel background="transparent" padding="lg" interactive border="default">
<p>
<strong>transparent</strong> → hover: step.1
</p>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Per-emphasis</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Each emphasis steps within its own concrete ramp.{" "}
<strong>Tertiary</strong> uses the neutral surface ramp,{" "}
<strong>secondary</strong> uses the colorPalette surface ramp, and{" "}
<strong>primary</strong> uses the colorPalette <em>accent</em> surface
ramp (anchored on shade 500).
</p>
<div class={grid({ columns: 3, gap: "md" })}>
<Panel emphasis="tertiary" tone="primary" padding="lg" interactive>
<p>
<strong>Tertiary</strong>
</p>
<p class={css({ fontSize: "sm" })}>Steps within neutral.surface.*.</p>
</Panel>
<Panel
emphasis="secondary"
tone="primary"
padding="lg"
interactive
border="default"
>
<p>
<strong>Secondary</strong>
</p>
<p class={css({ fontSize: "sm" })}>
Steps within colorPalette.surface.*.
</p>
</Panel>
<Panel emphasis="primary" tone="primary" padding="lg" interactive>
<p>
<strong>Primary</strong>
</p>
<p class={css({ fontSize: "sm" })}>
Steps within colorPalette.accent.surface.*.
</p>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Translucent</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Translucent + interactive — backdrop blur with hover tone.
</p>
<div
class={css({
position: "relative",
padding: "md",
borderRadius: "md",
overflow: "hidden",
background:
"linear-gradient(135deg, token(colors.primary) 0%, token(colors.primary.surface.peak) 50%, token(colors.primary) 100%)",
})}
>
<svg
class={css({
position: "absolute",
top: "0",
left: "0",
width: "full",
height: "full",
})}
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="20%"
cy="30%"
r="60"
fill="var(--colors-primary-surface)"
></circle>
<circle
cx="80%"
cy="60%"
r="80"
fill="var(--colors-primary-border-muted)"
></circle>
<rect
x="40%"
y="10%"
width="100"
height="100"
fill="var(--colors-primary-border)"
rx="15"
></rect>
<circle
cx="60%"
cy="80%"
r="50"
fill="var(--colors-primary-hover)"
></circle>
</svg>
<Panel
translucent
padding="md"
radius="xs"
interactive
class={css({ position: "relative", zIndex: "1" })}
data-ir="3"
>
<p>
<strong>Frosted glass with hover</strong>
</p>
<p>Hover to see the interactive state on the blurred panel.</p>
</Panel>
</div>
</div>
</div>
<script>
import { hydrate } from "./_client/interactive.ts";
document
.querySelectorAll('[data-ir-root="panel/interactive"]')
.forEach((root) => hydrate([root]));
</script>Where interactive says a panel can be clicked, active says it is the current choice in a set — the current navigation item, a checked radio in button appearance, a selected choice card.
Combined with interactive, hover and press restart their stepping from the active baseline instead of the resting one — a chosen row still answers the pointer rather than sliding back into the unchosen ramp. Without interactive, an active panel is inert under the pointer.
activeEmphasisactiveEmphasis controls the state’s intensity. It defaults to the panel’s own emphasis, so the chosen state lands on the same ramp as the resting look — a tertiary panel gets a neutral tint, a primary one travels the accent ramp. Set it explicitly only when the active item should be louder (or quieter) than the panel itself.
activeEmphasis | Active surface | Text / border | Surface Δ |
|---|---|---|---|
"secondary" | tint on colorPalette.surface.* | text.bold / border | ~10 units |
"tertiary" | tint on neutral.surface.* (colorless) | neutral.text.bold / neutral.border | ~7 units |
"primary" | fill on colorPalette.accent.surface.* | contrast scale / border.contrast.bold | ~150 units |
"highlight" | colorPalette.highlight.*, every state | text.bold / border | fixed per mode |
Selected vs current. A selected panel — active (true) or active="checked" — at the two tints paints its family’s highlight instead of a surface step: the hue at a fixed lightness off the page, with its own hover and press steps (neutral.highlight.* at "tertiary"). It reads the same on every background and every tone. The current location — active="current" / "current-within" — keeps the surface tint, so where you are stays quieter than what you picked. To highlight the current item too, set activeEmphasis="highlight": it paints the highlight for every active value.
The two tints shift the surface two steps toward deep (reversing toward soft when +2 would overflow), one more than interactive uses for hover so “chosen” doesn’t read as “hovered”. Be aware how small those steps are: measured on the shipped dark theme a 2-step move is only ~7–18 RGB units. Since the default follows emphasis, a default (tertiary) panel therefore gets the subtlest state — on a dense list, override with activeEmphasis="primary" so the chosen row reads at a glance. transparent is the exception at every level: it gains a fill outright, which is itself the state change.
<!-- follows emphasis — neutral panel, neutral tint -->
<Panel interactive active>…</Panel>
<!-- override: quiet panel, loud chosen state -->
<Panel interactive active activeEmphasis="primary">…</Panel>
activeEmphasis="primary" on a panel that is already an accent fill (emphasis="primary") can’t simply become one, so it travels to the far end of the accent ramp instead and the border.contrast.bold ring carries the state.
active takes three values, differing only in what drives the state:
| Value | Driven by | Use for |
|---|---|---|
true | data-panel-active="true", baked by the connect | Components whose framework state knows the answer at render time |
"checked" | :has(input:checked) | A wrapped native radio/checkbox — self-driving, zero JS, nothing to re-render |
"current" | [aria-current] (any value but false) | Links already carrying aria-current="page" for accessibility |
:has(input:checked) is deliberately opt-in rather than always-on, so a panel that merely contains a form with a checked box doesn’t light up as chosen.
The attribute is
data-panel-active, not a baredata-active. Panda compiles its_active(pressed) condition to&:is(:active, [data-active]), so a plaindata-activewould trigger every pressed rule in the recipe and an active panel would render permanently held down. Query[data-panel-active]in tests and custom CSS.
interactive says a panel can be clicked; active says it is the current choice. The active surface sits two steps deeper in the panel's own ramp — two rather than one, so it stays distinguishable from a mere hover — and text promotes to bold with the border lifting to the default line.
With interactive, hover and press restart their stepping from the active baseline, so the chosen row still answers the pointer instead of sliding back into the unchosen ramp.
active="checked" — driven by the DOM, zero JSWrap a native radio or checkbox and the panel follows :has(input:checked) — nothing re-renders, no state to thread. This is the arm a checkbox/radio button appearance or a selectable card wants. It's opt-in precisely so a panel that merely contains a checked box doesn't light up as chosen.
active="current" — free for linksMatches [aria-current] (any value but false), so a link that already carries aria-current="page" for accessibility gets the visual state with no extra prop. Rendered as buttons here — it's the attribute, not the tag, that drives the state.
activeEmphasis — how loud the state isIndependent of the panel's own emphasis, which still owns the resting look. secondary (the default) tints the tone surface ramp, tertiary tints the neutral ramp, and primary fills with the tone accent ramp and flips text to the contrast scale. The tints move the surface only ~7–18 RGB units — on a dense list, activeEmphasis="primary" (~150) is the one that reads at a glance. Each row below is resting / active.
activeEmphasis="secondary"
activeEmphasis="tertiary"
activeEmphasis="primary"
emphasisactiveEmphasis defaults to the panel's own emphasis, so with no override the chosen state lands on the same ramp as the resting look — tertiary on neutral.surface.*, secondary on colorPalette.surface.*, and primary travelling colorPalette.accent.surface.*. Each pair below is resting / active, with no activeEmphasis set.
interactiveA read-only "this is the current one" marker. It paints the active surface but stays inert under the pointer — hover and press collapse back onto the resting active background, so nothing moves.
Hover me — nothing happens.
---
// GENERATED from packages/demos/panel/active.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const pages = ["Overview", "Components", "Tokens", "Changelog"];
const plans = ["Starter", "Pro", "Enterprise"];
const levels = ["secondary", "tertiary", "primary"] as const;
const emphases = ["tertiary", "secondary", "primary"] as const;
const currentPage = "Components";
---
<div
class={stack({ gap: "2xl", direction: "column", width: "full" })}
data-ir-root="panel/active"
>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>active — the chosen one in a set</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
<code>interactive</code> says a panel <em>can</em> be clicked;{" "}
<code>active</code> says it <em>is</em> the current choice. The active
surface sits two steps deeper in the panel's own ramp — two rather than
one, so it stays distinguishable from a mere hover — and text promotes to{" "}
<code>bold</code> with the border lifting to the default line.
</p>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
With <code>interactive</code>, hover and press <strong>restart</strong>{" "}
their stepping from the active baseline, so the chosen row still answers
the pointer instead of sliding back into the unchosen ramp.
</p>
<div class={stack({ gap: "2xs", direction: "column" })}>
<!--ir:1-->
{pages.map((page) => (
<Panel
as="button"
background="transparent"
padding="sm"
radius="md"
interactive
active={page === currentPage}
class={css({ textAlign: "left", width: "full" })}
data-ir="2"
data-ir-key={page}
>
<Fragment set:html="<!--ir:3-->" />
{page}
<Fragment set:html="<!--/ir-->" />
</Panel>
))}
<!--/ir-->
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
<code>active="checked"</code> — driven by the DOM, zero JS
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Wrap a native radio or checkbox and the panel follows{" "}
<code>:has(input:checked)</code> — nothing re-renders, no state to thread.
This is the arm a checkbox/radio button appearance or a selectable card
wants. It's opt-in precisely so a panel that merely <em>contains</em> a
checked box doesn't light up as chosen.
</p>
<div class={grid({ columns: 3, gap: "md" })}>
<!--ir:4-->
{plans.map((plan) => (
<Panel
as="label"
background="transparent"
padding="lg"
interactive
active="checked"
class={css({ display: "flex", alignItems: "center", gap: "sm" })}
data-ir-key={plan}
>
<input
type="radio"
name="astro-demo-panel-demo-plan"
value={plan}
checked={plan === "Pro"}
data-ir="5"
/>
<strong>
<!--ir:6-->
{plan}
<!--/ir-->
</strong>
</Panel>
))}
<!--/ir-->
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
<code>active="current"</code> — free for links
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Matches <code>[aria-current]</code> (any value but <code>false</code>), so
a link that already carries <code>aria-current="page"</code> for
accessibility gets the visual state with no extra prop. Rendered as
buttons here — it's the attribute, not the tag, that drives the state.
</p>
<div class={stack({ gap: "2xs", direction: "column" })}>
<Panel
as="button"
padding="sm"
radius="md"
interactive
active="current"
aria-current="page"
>
Docs{" "}
<span class={css({ color: "panel.text.muted" })}>
— aria-current="page"
</span>
</Panel>
<Panel as="button" padding="sm" radius="md" interactive active="current">
Blog{" "}
<span class={css({ color: "panel.text.muted" })}>
— no aria-current
</span>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
<code>activeEmphasis</code> — how loud the state is
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Independent of the panel's own <code>emphasis</code>, which still owns the
resting look. <strong>secondary</strong> (the default) tints the tone
surface ramp, <strong>tertiary</strong> tints the neutral ramp, and{" "}
<strong>primary</strong> fills with the tone <em>accent</em> ramp and
flips text to the contrast scale. The tints move the surface only ~7–18
RGB units — on a dense list, <code>activeEmphasis="primary"</code> (~150)
is the one that reads at a glance. Each row below is resting / active.
</p>
<div class={grid({ columns: 3, gap: "md" })}>
<!--ir:7-->
{levels.map((level) => (
<div
class={stack({ gap: "2xs", direction: "column" })}
data-ir-key={level}
>
<p class={css({ fontSize: "xs", color: "panel.text.muted" })}>
activeEmphasis="<!--ir:8-->
{level}
<!--/ir-->"
</p>
<Panel tone="primary" padding="md" interactive>
resting
</Panel>
<Panel
tone="primary"
padding="md"
interactive
active
activeEmphasis={level}
data-ir="9"
>
active
</Panel>
</div>
))}
<!--/ir-->
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
The derived default — active follows <code>emphasis</code>
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
<code>activeEmphasis</code> defaults to the panel's own{" "}
<code>emphasis</code>, so with no override the chosen state lands on the
same ramp as the resting look — <strong>tertiary</strong> on{" "}
<code>neutral.surface.*</code>, <strong>secondary</strong> on{" "}
<code>colorPalette.surface.*</code>, and <strong>primary</strong>{" "}
travelling <code>colorPalette.accent.surface.*</code>. Each pair below is
resting / active, with no <code>activeEmphasis</code> set.
</p>
<div class={grid({ columns: 3, gap: "md" })}>
<!--ir:10-->
{emphases.map((emphasis) => (
<div
class={stack({ gap: "2xs", direction: "column" })}
data-ir-key={emphasis}
>
<Panel
emphasis={emphasis}
tone="primary"
padding="md"
interactive
data-ir="11"
>
<strong>
<!--ir:12-->
{emphasis}
<!--/ir-->
</strong>{" "}
— resting
</Panel>
<Panel
emphasis={emphasis}
tone="primary"
padding="md"
interactive
active
data-ir="13"
>
<strong>
<!--ir:14-->
{emphasis}
<!--/ir-->
</strong>{" "}
— active
</Panel>
</div>
))}
<!--/ir-->
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
Active without <code>interactive</code>
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
A read-only "this is the current one" marker. It paints the active surface
but stays inert under the pointer — hover and press collapse back onto the
resting active background, so nothing moves.
</p>
<Panel padding="lg" active>
<strong>Current selection</strong>
<p class={css({ fontSize: "sm" })}>Hover me — nothing happens.</p>
</Panel>
</div>
</div>
<script>
import { hydrate } from "./_client/active.ts";
document
.querySelectorAll('[data-ir-root="panel/active"]')
.forEach((root) => hydrate([root]));
</script>Control the internal spacing of a panel using the padding prop. Panels default to md padding.
No padding
Small padding
Medium padding (default)
Large padding
---
// GENERATED from packages/demos/panel/padding.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column", width: "full" })}>
<Panel background="surface.base" padding="none" border="default">
<p>No padding</p>
</Panel>
<Panel background="surface.base" padding="sm" border="default">
<p>Small padding</p>
</Panel>
<Panel background="surface.base" padding="md" border="default">
<p>Medium padding (default)</p>
</Panel>
<Panel background="surface.base" padding="lg" border="default">
<p>Large padding</p>
</Panel>
</div>Control corner rounding with the radius prop. Ranges from none to full.
No border radius
Small border radius
Medium border radius
Large border radius
Extra large border radius (default)
Full border radius
---
// GENERATED from packages/demos/panel/radius.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column", width: "full" })}>
<Panel background="surface.ground" padding="md" radius="none" border="bold">
<p>No border radius</p>
</Panel>
<Panel background="surface.ground" padding="md" radius="sm" border="bold">
<p>Small border radius</p>
</Panel>
<Panel background="surface.ground" padding="md" radius="md" border="bold">
<p>Medium border radius</p>
</Panel>
<Panel background="surface.ground" padding="md" radius="lg" border="bold">
<p>Large border radius</p>
</Panel>
<Panel background="surface.ground" padding="md" radius="xl" border="bold">
<p>Extra large border radius (default)</p>
</Panel>
<Panel background="surface.ground" padding="md" radius="full" border="bold">
<p>Full border radius</p>
</Panel>
</div>Use radius="inner" on a nested Panel to keep its corners visually concentric with the parent’s curve. The child renders max(2xs, parent_radius - parent_padding) computed against its immediate parent panel’s exported --panel-out-radius / --panel-out-padding custom properties. No JavaScript, no observers — pure CSS.
The subtracted inset is the parent’s padding prop, which is the only padding the cascade can see. Padding applied any other way — a class, a passThrough style, or a plain wrapper element between the two panels — contributes nothing, and the child subtracts zero:
<!-- ✗ the inset is invisible to the cascade: the child renders the PARENT's corner -->
<Panel radius="xl" padding="none" class={css({ padding: "md" })}>
<Panel radius="inner">…</Panel>
</Panel>
<!-- ✓ -->
<Panel radius="xl" padding="md">
<Panel radius="inner">…</Panel>
</Panel>
That failure is silent and reads as “inner is broken”: max(2xs, R - 0) is R, so the child traces its parent’s curve instead of nesting inside it. It’s also the correct answer when a child really is flush to the edge — with padding="none" and no inset, matching corners is concentric. Use Padding an element that isn’t a Panel when the inset lives somewhere the cascade can’t reach.
radius="inner"The child on the left uses the default radius="xl"; its corners protrude past the parent's. The child on the right uses radius="inner" and stays visually concentric.
Default radius="xl"
Corners don't match the parent's curve.
radius="inner"
Auto-fits the parent's curve.
The child has identical props in every panel below — only the parent's radius and padding change.
Parent: radius="lg", padding="xs"
Parent: radius="2xl", padding="md"
Parent: radius="4xl", padding="lg"
2xsWhen parent_radius − parent_padding would go below the 2xs radius, the formula clamps. This keeps inner corners from collapsing to a sharp edge.
Parent: radius="sm", padding="lg" → child clamps to 2xs.
---
// GENERATED from packages/demos/panel/inner-radius.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div class={stack({ gap: "2xl", direction: "column", width: "full" })}>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
With vs without <code>radius="inner"</code>
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
The child on the left uses the default <code>radius="xl"</code>; its
corners protrude past the parent's. The child on the right uses{" "}
<code>radius="inner"</code> and stays visually concentric.
</p>
<div class={grid({ columns: 2, gap: "lg" })}>
<Panel padding="sm" radius="2xl" border="bold">
<Panel background="surface.ground" padding="md" border="bold">
<p class={css({ fontSize: "sm" })}>
<strong>Default radius="xl"</strong>
</p>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Corners don't match the parent's curve.
</p>
</Panel>
</Panel>
<Panel padding="sm" radius="2xl" border="bold">
<Panel
background="surface.ground"
padding="md"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm" })}>
<strong>radius="inner"</strong>
</p>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Auto-fits the parent's curve.
</p>
</Panel>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Adapts to any parent radius + padding</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
The child has identical props in every panel below — only the parent's{" "}
<code>radius</code> and <code>padding</code> change.
</p>
<div class={stack({ gap: "md", direction: "column" })}>
<Panel padding="xs" radius="lg" border="bold">
<Panel
background="surface.ground"
padding="md"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm" })}>
Parent: <code>radius="lg"</code>, <code>padding="xs"</code>
</p>
</Panel>
</Panel>
<Panel padding="md" radius="2xl" border="bold">
<Panel
background="surface.ground"
padding="md"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm" })}>
Parent: <code>radius="2xl"</code>, <code>padding="md"</code>
</p>
</Panel>
</Panel>
<Panel padding="lg" radius="4xl" border="bold">
<Panel
background="surface.ground"
padding="md"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm" })}>
Parent: <code>radius="4xl"</code>, <code>padding="lg"</code>
</p>
</Panel>
</Panel>
</div>
</div>
<div class={stack({ gap: "md", direction: "column" })}>
<h3>
Lower bound: clamps to <code>2xs</code>
</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
When <code>parent_radius − parent_padding</code> would go below the{" "}
<code>2xs</code> radius, the formula clamps. This keeps inner corners from
collapsing to a sharp edge.
</p>
<Panel padding="lg" radius="sm" border="bold">
<Panel
background="surface.ground"
padding="md"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm" })}>
Parent: <code>radius="sm"</code>, <code>padding="lg"</code> → child
clamps to <code>2xs</code>.
</p>
</Panel>
</Panel>
</div>
</div>radius="inner" recurses: each level steps down relative to its immediate parent panel, and a panel that resolves an inner corner republishes the value for its own children — concentric curves to a depth of 8 nested panels (deeper levels stay valid, just slightly rounder than the exact math). The 2xs floor keeps deep corners from collapsing to a sharp edge.
Four levels deep: 4xl → inner → inner → inner. Each corner steps down by its parent's padding.
The floor: by the third level 2xl − md − md bottoms out, so this corner clamps at xs instead of going sharp.
Parent radius="full" — use explicit "full" since inner would clamp to xs.
---
// GENERATED from packages/demos/panel/nesting.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div class={stack({ gap: "xl", direction: "column", width: "full" })}>
<Panel padding="lg" radius="4xl" border="bold">
<Panel background="surface.peak" padding="md" radius="inner" border="bold">
<Panel
background="surface.ground"
padding="sm"
radius="inner"
border="bold"
>
<Panel
background="surface.hill"
padding="sm"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm", color: "fg.subtle" })}>
Four levels deep: <code>4xl</code> → inner → inner → inner. Each
corner steps down by its parent's padding.
</p>
</Panel>
</Panel>
</Panel>
</Panel>
<Panel padding="md" radius="2xl" border="bold">
<Panel background="surface.peak" padding="md" radius="inner" border="bold">
<Panel
background="surface.ground"
padding="md"
radius="inner"
border="bold"
>
<p class={css({ fontSize: "sm", color: "fg.subtle" })}>
The floor: by the third level <code>2xl − md − md</code> bottoms out,
so this corner clamps at <code>xs</code> instead of going sharp.
</p>
</Panel>
</Panel>
</Panel>
<Panel padding="md" radius="full" border="bold">
<Panel background="surface.ground" padding="md" radius="full" border="bold">
<p class={css({ fontSize: "sm", color: "fg.subtle" })}>
Parent radius="full" — use explicit "full" since inner would clamp to
xs.
</p>
</Panel>
</Panel>
</div>Only panels are levels of the cascade. When the padding between a panel and its inner child belongs to something else — a layout <div>, a <ul>, a grid cell — make that element a level too, with the panelNest pattern plus the data-panel-nest attribute the cascade is keyed on:
<script>
import { panelNest } from "@pindoba/styled-system/patterns";
</script>
<Panel radius="xl" padding="none">
<div data-panel-nest class={panelNest({ padding: "md" })}>
<Panel radius="inner">…</Panel>
<!-- corner = xl − md, exactly as if the div were a Panel -->
</div>
</Panel>
Both parts are required: the vars without the attribute do nothing, since the subtraction runs on [data-panel-nest] elements only.
| prop | default | effect |
|---|---|---|
padding | none | The inset children sit behind. Subtracted from this element’s corner, and emitted as a real padding declaration. |
radius | inherits chain | This element’s own corner. Omit it for a transparent wrapper — it then resolves through the cascade like radius="inner" would. |
setPadding | true | Set false to publish the var without the padding declaration, when the real padding is asymmetric or already declared elsewhere. |
An opted-in element consumes one of the cascade’s 8 levels, the same as a Panel. If the wrapper is yours to change, <Panel radius="inner" padding="md" background="transparent" border="none"> produces an identical corner and needs no attribute — prefer it, and reach for panelNest when the padded element isn’t yours to replace.
To pin a corner with no DOM relationship at all, the innerRadius utility takes the two values literally (e.g. innerRadius: "2xl.md" via passThrough.root.style). Nothing cascades — you’re stating the parent’s geometry by hand, so it drifts the moment the parent’s radius or padding changes.
Use the as prop to change the HTML element rendered by the panel. This enables semantic markup — render as <section>, <article>, <nav>, or any other HTML element while preserving all Panel styling and behavior. The as prop accepts any valid HTML tag name.
Default panel renders as a div element.
Renders as a semantic section element.
Renders as an article element for self-contained content.
Renders as a main element for primary page content.
---
// GENERATED from packages/demos/panel/polymorphic.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
})}
>
<div>
<h3>div (default)</h3>
<Panel padding="lg">
<p>Default panel renders as a div element.</p>
</Panel>
</div>
<div>
<h3>section</h3>
<Panel as="section" padding="lg" border="default">
<p>Renders as a semantic section element.</p>
</Panel>
</div>
<div>
<h3>article</h3>
<Panel as="article" padding="lg" background="surface.base">
<p>Renders as an article element for self-contained content.</p>
</Panel>
</div>
<div>
<h3>main</h3>
<Panel as="main" padding="lg" background="surface.peak" border="muted">
<p>Renders as a main element for primary page content.</p>
</Panel>
</div>
<div>
<h3>aside</h3>
<Panel as="aside" padding="lg" background="surface.ground">
<p>Renders as an aside element for sidebar content.</p>
</Panel>
</div>
<div>
<h3>nav</h3>
<Panel as="nav" padding="lg" background="transparent" border="default">
<p>Renders as a nav element for navigation.</p>
</Panel>
</div>
</div>The passThrough prop provides two escape hatches for advanced customization: style accepts any Panda CSS SystemStyleObject applied directly to the panel root, and props forwards arbitrary HTML attributes. Use style for layout overrides like maxWidth or responsive constraints, and props for accessibility attributes like role, aria-label, or aria-live.
Centered with Max Width
Use passThrough.root.style to apply Panda CSS styles directly to the panel root — here constraining width and centering the panel.
This action cannot be undone. All data will be permanently removed.
Your session expires in 5 minutes
Use passThrough.root.props to set HTML attributes — here role="alert" and aria-live="polite" for screen reader announcements.
---
// GENERATED from packages/demos/panel/custom.demo.tsx — edit the source, not this file.
import Panel from "@pindoba/astro-panel";
import Button from "@pindoba/astro-button";
import { stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div
class={stack({
gap: "xl",
direction: "column",
})}
>
<div>
<h3>Custom Width</h3>
<Panel
background="surface.base"
padding="lg"
passThrough={{
root: {
style: css.raw({
maxWidth: "480px",
margin: "0 auto",
}),
},
}}
>
<p>
<strong>Centered with Max Width</strong>
</p>
<p>
Use <code>passThrough.root.style</code> to apply Panda CSS styles
directly to the panel root — here constraining width and centering the
panel.
</p>
</Panel>
</div>
<div>
<h3>Panel as Card</h3>
<Panel background="surface.base" padding="lg" border="default">
<div class={stack({ gap: "md" })}>
<div>
<h4 class={css({ margin: "0", marginBottom: "2xs" })}>
Delete workspace
</h4>
<p class={css({ margin: "0", color: "fg.subtle", fontSize: "md" })}>
This action cannot be undone. All data will be permanently removed.
</p>
</div>
<div class={flex({ gap: "sm", justify: "flex-end" })}>
<Button emphasis="secondary" background="surface.ground">
Cancel
</Button>
<Button emphasis="primary" tone="danger">
Delete
</Button>
</div>
</div>
</Panel>
</div>
<div>
<h3>Accessible Alert</h3>
<Panel
background="surface.peak"
tone="warning"
padding="lg"
border="default"
passThrough={{
root: {
props: {
role: "alert",
"aria-live": "polite",
"aria-label": "Session expiry warning",
},
},
}}
>
<p>
<strong>Your session expires in 5 minutes</strong>
</p>
<p>
Use <code>passThrough.root.props</code> to set HTML attributes — here{" "}
<code>role="alert"</code> and <code>aria-live="polite"</code> for screen
reader announcements.
</p>
</Panel>
</div>
</div>Panel is the foundational surface used by Card, Dialog, Alert, Badge, Banner, Input, and many other components. When you build a new component that should look like a panel — and most surface-like components should — wire it up through connectPanel() instead of redeclaring the variants.
PanelInheritedProps on your prop typePanelInheritedProps carries every Panel visual prop (background, tone, emphasis, translucent, padding, radius + per-side variants, border, shadow, interactive) plus as and passThrough. Extending it gives your component the full Panel API surface for free.
import type { PanelInheritedProps } from "@pindoba/core-panel";
export interface SurfaceBaseProps extends PanelInheritedProps {
// your component's own props go here
size?: "sm" | "md" | "lg";
}
If your component has its own HTML attribute type that overlaps with Panel’s props, use OmitPanelProps<T> to strip them cleanly.
connectPanel() from your connect functionPass your component’s slot-root styles via inheritedStyle, and any static attrs (e.g. role) via inheritedProps. The end user’s passThrough flows through untouched — connectPanel handles the merge.
import { connectPanel } from "@pindoba/core-panel";
import { surfaceStyles } from "@pindoba/styles-surface";
export function connectSurface(options: ConnectSurfaceOptions) {
const { size = "md", passThrough, class: className, ...rest } = options;
const slot = surfaceStyles.raw({ size });
return connectPanel({
dataComponent: "surface",
...rest,
inheritedStyle: slot.root,
passThrough,
class: className,
});
}
Once your component is wired up, here’s the precedence Panel guarantees — useful to remember when debugging or designing overrides:
Class composition (low → high specificity):
background, tone, etc.)inheritedStylepassThrough.root.styleclass="..." string (appended last)Root attributes (low → high precedence):
data-component (your dataComponent option) and data-slot="root"dataAttrs (your dynamic data-* attrs)inheritedProps (your static attrs)...rest)passThrough.root.props (always wins)This means: end users can override anything your component sets without you having to plumb each attr individually. Set sane defaults via inheritedProps; trust users to override when they need to.
connectCard is the canonical example: it adds a size prop, derives a default radius, layers its own slot styles, and delegates everything else to connectPanel.