Renders as an article element for self-contained content.
Renders as a main element for primary page content.
component
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 feedback colors, interactive states, and extensive customization options for building cards, sections, and container elements.
Up 12.4% versus last month — driven by stronger Pro plan retention and a healthy bump in annual upgrades.
Panel supports five surface levels (surface.peak through surface.ground) plus transparent. Surface 1 is the lightest and surface 5 is the darkest — use them to create visual hierarchy within your layout. Use the translucent prop to add a frosted glass effect.
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.
---
import Panel from "../Panel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
})}
>
<!-- Surface.1 (lightest) -->
<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>
<!-- Surface.2 -->
<div>
<h3>Surface 2</h3>
<Panel background="surface.hill" padding="lg">
<p>Slightly darker surface for subtle layering.</p>
</Panel>
</div>
<!-- Surface.3 (mid) -->
<div>
<h3>Surface 3</h3>
<Panel background="surface.base" padding="lg">
<p>Mid-tone surface for visual prominence.</p>
</Panel>
</div>
<!-- Surface.4 -->
<div>
<h3>Surface 4</h3>
<Panel background="surface.valley" padding="lg">
<p>Darker surface for nested containers or secondary areas.</p>
</Panel>
</div>
<!-- Surface.5 (darkest) -->
<div>
<h3>Surface 5</h3>
<Panel background="surface.ground" padding="lg">
<p>Darkest surface for recessed areas, wells, or secondary zones.</p>
</Panel>
</div>
<!-- Transparent -->
<div>
<h3>Transparent</h3>
<Panel background="transparent" padding="lg" border="default">
<p>No background — inherits from its parent container.</p>
</Panel>
</div>
<!-- Translucent -->
<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) 50%, token(colors.primary) 100%)",
})}
>
<!-- Decorative SVG shapes in background -->
<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 feedback colors to convey meaning and context. Panels default to a neutral (gray) color palette. All five feedback colors (neutral, primary, success, warning, danger) work with all three emphasis levels.
Emphasis Secondary
colorPalette.surface — neutral tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of feedback.
Emphasis Primary
Full-color neutral (500) background with contrast text.
Emphasis Secondary
colorPalette.surface — palette-aware tinted background.
Emphasis Tertiary
neutral.surface — always neutral regardless of feedback.
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 feedback.
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 feedback.
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 feedback.
Emphasis Primary
Full-color danger (500) background with contrast text.
---
import Panel from "../Panel.astro";
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
feedback="neutral"
emphasis="secondary"
padding="lg"
border="default"
>
<p><strong>Emphasis Secondary</strong></p>
<p>colorPalette.surface — neutral tinted background.</p>
</Panel>
<Panel
feedback="neutral"
emphasis="tertiary"
padding="lg"
border="default"
>
<p><strong>Emphasis Tertiary</strong></p>
<p>neutral.surface — always neutral regardless of feedback.</p>
</Panel>
<Panel feedback="neutral" emphasis="primary" padding="lg">
<p><strong>Emphasis Primary</strong></p>
<p>Full-color neutral (500) background with contrast text.</p>
</Panel>
</div>
</div>
<div>
<h3>Primary</h3>
<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
<Panel
feedback="primary"
emphasis="secondary"
padding="lg"
border="default"
>
<p><strong>Emphasis Secondary</strong></p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel
feedback="primary"
emphasis="tertiary"
padding="lg"
border="default"
>
<p><strong>Emphasis Tertiary</strong></p>
<p>neutral.surface — always neutral regardless of feedback.</p>
</Panel>
<Panel feedback="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
feedback="success"
emphasis="secondary"
padding="lg"
border="default"
>
<p><strong>Emphasis Secondary</strong></p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel
feedback="success"
emphasis="tertiary"
padding="lg"
border="default"
>
<p><strong>Emphasis Tertiary</strong></p>
<p>neutral.surface — always neutral regardless of feedback.</p>
</Panel>
<Panel feedback="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
feedback="warning"
emphasis="secondary"
padding="lg"
border="default"
>
<p><strong>Emphasis Secondary</strong></p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel
feedback="warning"
emphasis="tertiary"
padding="lg"
border="default"
>
<p><strong>Emphasis Tertiary</strong></p>
<p>neutral.surface — always neutral regardless of feedback.</p>
</Panel>
<Panel feedback="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
feedback="danger"
emphasis="secondary"
padding="lg"
border="default"
>
<p><strong>Emphasis Secondary</strong></p>
<p>colorPalette.surface — palette-aware tinted background.</p>
</Panel>
<Panel
feedback="danger"
emphasis="tertiary"
padding="lg"
border="default"
>
<p><strong>Emphasis Tertiary</strong></p>
<p>neutral.surface — always neutral regardless of feedback.</p>
</Panel>
<Panel feedback="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>Three emphasis levels control how the feedback color is applied. tertiary (the default) always sits on the neutral surface ramp with feedback-colored border and accent text — the most subtle option. secondary uses palette-aware surface tints. primary fills with the feedback 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 feedback color applied to the border and accent text. The most subtle emphasis and the new default.
Primary feedback
Neutral surface, primary border + accent text.
Success feedback
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 feedback color.
surface.peak
Default layer.
surface.base
Mid-tone palette surface.
surface.ground
Deepest palette surface.
transparent
No background, palette text.
Fills with the feedback color's accent.surface.* ramp (anchored on shade 500) and uses contrast text. The backgroundprop 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 feedback color generates its own accent.surface ramp around its shade 500.
Primary
Success
Warning
Danger
---
import Panel from "../Panel.astro";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div class={stack({ gap: "2xl", direction: "column", width: "100%" })}>
<!-- Tertiary emphasis (default) -->
<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 feedback 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" feedback="primary" padding="lg">
<p><strong>Primary feedback</strong></p>
<p class={css({ fontSize: "sm" })}>
Neutral surface, primary border + accent text.
</p>
</Panel>
<Panel emphasis="tertiary" feedback="success" padding="lg">
<p><strong>Success feedback</strong></p>
<p class={css({ fontSize: "sm" })}>
Neutral surface, success border + accent text.
</p>
</Panel>
<Panel
emphasis="tertiary"
feedback="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" feedback="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>
<!-- Secondary emphasis -->
<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 feedback color.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel
emphasis="secondary"
feedback="primary"
padding="lg"
border="default"
>
<p><strong>surface.peak</strong></p>
<p class={css({ fontSize: "sm" })}>Default layer.</p>
</Panel>
<Panel
emphasis="secondary"
feedback="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"
feedback="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"
feedback="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>
<!-- Primary emphasis -->
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Primary</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Fills with the feedback 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" feedback="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"
feedback="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"
feedback="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"
feedback="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>
<!-- Primary across feedback colors -->
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Primary across feedback colors</h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Each feedback color generates its own accent.surface ramp around its shade
500.
</p>
<div class={grid({ columns: 2, gap: "md" })}>
<Panel emphasis="primary" feedback="primary" padding="lg">
<p><strong>Primary</strong></p>
</Panel>
<Panel emphasis="primary" feedback="success" padding="lg">
<p><strong>Success</strong></p>
</Panel>
<Panel emphasis="primary" feedback="warning" padding="lg">
<p><strong>Warning</strong></p>
</Panel>
<Panel emphasis="primary" feedback="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
---
import Panel from "../Panel.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
width: "full",
})}
>
<!-- No Border -->
<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>
<!-- Default Border -->
<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>
<!-- Bold Border -->
<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>
<!-- Muted Border -->
<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>
<!-- Bordered with Feedback Colors -->
<div>
<h3>Border Styles with Feedback Colors</h3>
<div class={stack({ gap: "md", direction: "column" })}>
<Panel feedback="primary" padding="md" border="default">
<p><strong>Primary with Default Border</strong></p>
</Panel>
<Panel feedback="success" padding="md" border="default">
<p><strong>Success with Default Border</strong></p>
</Panel>
<Panel feedback="warning" padding="md" border="bold">
<p><strong>Warning with Bold Border</strong></p>
</Panel>
<Panel feedback="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.
---
import Panel from "../Panel.astro";
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.
Hover me
Hover steps to surface.hill; active to step.2.
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, andprimary 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 feedback.
Frosted glass with hover
Hover to see the interactive state on the blurred panel.
---
import Panel from "../Panel.astro";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div class={stack({ gap: "2xl", direction: "column", width: "full" })}>
<!-- Basic 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>
<p><strong>Hover me</strong></p>
<p>
Hover steps to <code>surface.hill</code>; active to <code>step.2</code>.
</p>
</Panel>
</div>
<!-- Stepping across backgrounds -->
<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>
<!-- Per-emphasis behavior -->
<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" feedback="primary" padding="lg" interactive>
<p><strong>Tertiary</strong></p>
<p class={css({ fontSize: "sm" })}>Steps within neutral.surface.*.</p>
</Panel>
<Panel
emphasis="secondary"
feedback="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" feedback="primary" padding="lg" interactive>
<p><strong>Primary</strong></p>
<p class={css({ fontSize: "sm" })}>
Steps within colorPalette.accent.surface.*.
</p>
</Panel>
</div>
</div>
<!-- Translucent over a busy backdrop -->
<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 feedback.
</p>
<div
class={css({
position: "relative",
padding: "md",
borderRadius: "md",
overflow: "hidden",
background:
"linear-gradient(135deg, token(colors.primary) 0%, token(colors.primary.surface) 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" })}
>
<p><strong>Frosted glass with hover</strong></p>
<p>Hover to see the interactive state on the blurred panel.</p>
</Panel>
</div>
</div>
</div>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 |
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) — no state to thread and, in Astro, no client-side JS at all. Click through the cards below: the selection is pure CSS. 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 feedback surface ramp, tertiary tints the neutral ramp, and primaryfills with the feedback 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.
---
import Panel from "../Panel.astro";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const pages = ["Overview", "Components", "Tokens", "Changelog"];
const currentPage = "Components";
const plans = ["Starter", "Pro", "Enterprise"];
const emphases = ["tertiary", "secondary", "primary"] as const;
const levels = ["secondary", "tertiary", "primary"] as const;
---
<div class={stack({ gap: "2xl", direction: "column", width: "full" })}>
<!-- Prop-driven arm -->
<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" })}>
{
pages.map((page) => (
<Panel
as="button"
background="transparent"
padding="sm"
radius="md"
interactive
active={page === currentPage}
>
{page}
</Panel>
))
}
</div>
</div>
<!-- DOM-driven: :has(input:checked) -->
<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
> — no state to thread and, in Astro, no client-side JS at all. Click through
the cards below: the selection is pure CSS. 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" })}>
{
plans.map((plan) => (
<Panel
as="label"
background="transparent"
padding="lg"
interactive
active="checked"
class={css({ display: "flex", alignItems: "center", gap: "sm" })}
>
<input
type="radio"
name="astro-panel-demo-plan"
value={plan}
checked={plan === "Pro"}
/>
<strong>{plan}</strong>
</Panel>
))
}
</div>
</div>
<!-- DOM-driven: aria-current -->
<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>
<!-- activeEmphasis: how loud -->
<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 feedback surface
ramp, <strong>tertiary</strong> tints the neutral ramp, and <strong
>primary</strong
>
fills with the feedback <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" })}>
{
levels.map((level) => (
<div class={stack({ gap: "2xs", direction: "column" })}>
<p class={css({ fontSize: "xs", color: "panel.text.muted" })}>
activeEmphasis="{level}"
</p>
<Panel feedback="primary" padding="md" interactive>
resting
</Panel>
<Panel
feedback="primary"
padding="md"
interactive
active
activeEmphasis={level}
>
active
</Panel>
</div>
))
}
</div>
</div>
<!-- Per-emphasis -->
<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" })}>
{
emphases.map((emphasis) => (
<div class={stack({ gap: "2xs", direction: "column" })}>
<Panel
emphasis={emphasis}
feedback="primary"
padding="md"
interactive
>
<strong>{emphasis}</strong> — resting
</Panel>
<Panel
emphasis={emphasis}
feedback="primary"
padding="md"
interactive
active
>
<strong>{emphasis}</strong> — active
</Panel>
</div>
))
}
</div>
</div>
<!-- Non-interactive active -->
<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>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
---
import Panel from "../Panel.astro";
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
---
import Panel from "../Panel.astro";
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(xs, 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(xs, 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 usesradius="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'sradius and padding change.
Parent: radius="lg", padding="xs"
Parent: radius="2xl", padding="md"
Parent: radius="4xl", padding="lg"
xsWhen parent_radius − parent_padding would go below thexs radius, the formula clamps. This keeps inner corners from collapsing to a sharp edge.
Parent: radius="sm", padding="lg" → child clamps to xs.
---
import Panel from "../Panel.astro";
import { stack, grid } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<!--
`radius="inner"` computes max(xs, parent_radius − parent_padding) by reading
the parent panel's exported `--panel-out-radius` / `--panel-out-padding`
custom properties. Use it on the child to keep visually-concentric corners
no matter what radius / padding the parent uses.
-->
<div class={stack({ gap: "2xl", direction: "column", width: "full" })}>
<!-- Side-by-side: with vs without inner radius -->
<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>
<!-- Adapts to every parent radius/padding combo -->
<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>
<!-- xs floor: when the formula would drop below xs, it clamps to xs -->
<div class={stack({ gap: "md", direction: "column" })}>
<h3>Lower bound: clamps to <code>xs</code></h3>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
When <code>parent_radius − parent_padding</code> would go below the
<code>xs</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>xs</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 xs 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.
---
import Panel from "../Panel.astro";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
---
<div class={stack({ gap: "xl", direction: "column", width: "full" })}>
<!--
radius="inner" recurses: every level computes
max(xs, parent_radius − parent_padding) against its IMMEDIATE parent and
republishes the resolved value for its own children — concentric curves
all the way down (depth 8), pure CSS.
-->
<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 precomputed inner.<radius>.<padding> semantic tokens take the two values literally (e.g. borderRadius: "inner.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.
---
import Panel from "../Panel.astro";
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 — hererole="alert" and aria-live="polite" for screen reader announcements.
---
import Panel from "../Panel.astro";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import { stack, flex } from "@pindoba/styled-system/patterns";
---
<div
class={stack({
gap: "xl",
direction: "column",
})}
>
<!-- Custom Width via PassThrough -->
<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>
<!-- Panel as Card with Action -->
<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" feedback="danger">Delete</Button>
</div>
</div>
</Panel>
</div>
<!-- Panel with ARIA Attributes -->
<div>
<h3>Accessible Alert</h3>
<Panel
background="surface.peak"
feedback="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, feedback, 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, feedback, 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.
falseMark this as the chosen one in a set (a current nav item, a checked option, a selected card). Where `interactive` says it *can* be clicked, `active` says it *is* the current choice. Use `activeEmphasis` to control how loud the state is. Combined with `interactive`, hover and press restart their stepping from the active baseline rather than falling back to the unchosen ramp. The three values differ only in what drives the state: `true` by the prop (via `data-panel-active`), `"checked"` by a wrapped native input (`:has(input:checked)` — zero JS, nothing to re-render), and `"current"` by an existing `aria-current`.
the panel's `emphasis`How loud the `active` state is. Defaults to the panel's own `emphasis`, so the chosen state lands on the same ramp as the resting look — this prop is the override for when the active item should be louder (or quieter) than the panel itself. `"primary"` fills with the feedback accent ramp and flips text to the contrast scale (~150 RGB units of separation — the option that reads at a glance in a dense list). `"secondary"` tints the feedback surface ramp and `"tertiary"` the neutral ramp; both promote text to `bold` and lift the border to the default line, but move the surface only ~7–18 units.
"div"HTML element to render. Curated to container-like tags so semantic intent stays clear (no html/script/style/etc).
Surface background from the Panel scale (`surface.peak` → `surface.ground`, or `transparent`).
Border style: `none`, `default`, `bold`, or `muted`.
No description yet.
Border style applied on hover / focus interaction.
No description yet.
User-supplied `class` string. Appended last via `cx()`.
Extra data attributes to emit on the root (e.g. `{ shape: "circle" }` → `data-shape="circle"`). Values of `undefined`, `null`, or `false` are skipped.
Override the default `data-component="panel"` attribute.
Bindable reference to the rendered element. Typed loosely as `Element` to stay compatible with `svelte:element`'s union return type.
Visual emphasis / prominence level.
Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.
Static HTML/data attrs produced by an inheriting component. Spread before the consumer's `restProps` and `passThrough.root.props`, so both can override them.
Styles produced by an inheriting component (e.g. `cardStyles.raw().root`). Merged into the panel root's class between the recipe and the consumer's `passThrough.root.style`, so consumer overrides still win. Inheritors use this instead of funneling their slot styles through `passThrough.root.style` manually.
Enable interactive (hover / focus / press) affordances and states.
Inner padding from the spacing scale.
Per-slot style and attribute override bag for customizing the panel's elements.
Corner radius override from the spacing scale (or `full` for fully rounded).
Corner radius for the bottom-left and bottom-right corners.
Corner radius for the top-left and bottom-left corners.
Corner radius for the top-right and bottom-right corners.
Corner radius for the top-left and top-right corners.
falseSerialize the panel's resolved surface inputs into a `data-panel-config` attribute on the root, enabling runtime surface changes via `updatePanelElement()` (and, for inheritors like Card, `updateCard()`) without a framework runtime — the primary consumer is Astro / vanilla JS, where props don't re-render. Reactive frameworks (Svelte/React/Vue) don't need this: their prop changes re-run the connect already.
Keep (or drop) the always-1px transparent border the panel reserves so a border appearing or changing never shifts layout. Computed automatically — it's reserved when a resting `border` is visible, or when `interactive` / `borderInteract` / `active` can change the border at runtime. Set `false` only on a panel that must not occupy that 1px (e.g. a borderless housing frame that would otherwise add 2px around a set).
Elevation shadow applied to the surface.
Apply a frosted-glass effect with backdrop blur over a surface background.
Plus all standard <anchor> HTML
attributes.