Marigold
v18.0.0-beta.4
Marigold
v18.0.0-beta.4
Release Notes

packages

@marigold/components@marigold/icons@marigold/system@marigold/types

themes

@marigold/theme-rui

docs

@marigold/docs

config

@marigold/eslint-config@marigold/prettier-config@marigold/tsconfig
ReleasesBlog

Marigold v18.0.0

For the last years, Marigold's job was mostly to replace. Modernize an aging codebase, swap out legacy patterns, refresh a design that had started to show its years. v18.0.0 is where that chapter quietly ends and a new one begins. We are done catching up. From here, the work is about craft. Shaping a design language that does not just look current, but is unmistakably its own.

This is the biggest single release we've shipped. It is also the first step of a longer effort, not the last. The foundation you'll build on is here. The identity layered on top of it is what comes next.

Before you upgrade

Marigold v18 requires React 19. If you are still on React 18, upgrade React first, verify your app still builds, then bump Marigold.

Breaking Changes

Design tokens overhaul

The design token layer has been rebuilt around a new purpose-built neutral palette, a clearer semantic naming scheme, and a restructured status-token hierarchy. The goal is to move from "tokens that happen to describe the current UI" to "tokens that describe the role each color plays." More changes will follow in later releases. v18 is the step that sets up that direction.

New charcoal palette

The default neutral scale has been replaced. Previously the theme leaned on Tailwind's stone palette with a handful of scattered overrides. It now uses a purpose-built warm neutral called charcoal, with 11 steps calibrated in OKLCH at hue 54. Each step maps to a specific role (background, hover, control, text, and so on).

StepOld (stone)New (charcoal)Typical role
50 #fafaf9 oklch(0.985 0.002 54)Subtle tint, primary-foreground
100 #f5f5f4 oklch(0.965 0.003 54)Page background
200 #e7e5e4 oklch(0.92 0.004 54)Hover, disabled-surface
300 #d6d3d1 oklch(0.86 0.005 54)Borders, control fill, selected
400 #a8a29e oklch(0.74 0.006 54)Disabled text
500 #78716c oklch(0.62 0.007 54)-
600 #57534e oklch(0.52 0.008 54)Focus ring, secondary text
700 #44403c oklch(0.42 0.008 54)-
800 #292524 oklch(0.32 0.008 54)-
900 #1c1917 oklch(0.22 0.008 54)Primary text
950 #0c0a09 oklch(0.15 0.008 54)Primary action, scrollbar hover

The new scale is more even across the light end (50 → 300), which gives more room for subtle UI layers on white surfaces, and it lands slightly cooler at the dark end so that body text reads with less warm cast. Full reference lives on the new Token Overview page.

Semantic token renames

A few long-standing token names were ambiguous enough that we kept tripping over them in review. Renaming them up front is less painful than maintaining the confusion across another year of components.

OldNewNotes
brandprimaryMatches the broader industry convention.
muted-foregroundsecondaryDescribes the role (secondary text), not the styling.
focusfocus-highlightDistinguishes the background highlight from the focus ring (ring).

In your application this is mostly a find-and-replace across Tailwind utility classes:

- <div className="bg-brand text-brand-foreground" />
+ <div className="bg-primary text-primary-foreground" />

- <Text className="text-muted-foreground">Help text</Text>
+ <Text className="text-secondary">Help text</Text>

- <div className="bg-focus ring-ring" />
+ <div className="bg-focus-highlight ring-ring" />

If you read these values directly as CSS custom properties (var(--color-brand)), update those too. The old variable names are gone, not aliased.

New surface and overlay tokens

Two additional tokens join the semantic set to cover roles that were previously expressed with raw utility classes:

  • disabled-surface: background for disabled control surfaces (switch tracks, selected-but-disabled states, inert chips). Resolves to charcoal-200 in the default theme.
  • overlay-backdrop: the scrim behind modals, drawers, and trays. Previously the backdrop was styled with a raw bg-black/50. Routing it through a token lets themes tune both the color and the opacity.

Status tokens restructured

Previously the theme exposed two parallel sets of tokens for status colors. A "solid" variant (--color-warning, --color-warning-foreground) was meant for icons on accent fills. A "muted" variant (--color-warning-muted, --color-warning-muted-foreground, --color-warning-muted-accent) was used for almost everything else (badges, banners, section messages). In practice, the muted set was the one you reached for 95% of the time, and the solid set caused more confusion than value.

The muted variant is now the default, and the -muted- segment has been dropped. Where the original bold variant is still useful (currently only for destructive), it moves to a -bold suffix.

- <div className="bg-warning-muted text-warning-muted-foreground">
-   <AlertTriangle className="text-warning-muted-accent" />
-   Heads up
- </div>
+ <div className="bg-warning text-warning-foreground">
+   <AlertTriangle className="text-warning-accent" />
+   Heads up
+ </div>

The same pattern applies across success, warning, info, and destructive. For destructive specifically, the strong solid variant is still available as bg-destructive-bold / text-destructive-bold-foreground for primary destructive actions.

If you relied on the old solid tokens (bg-warning, bg-info) to render a filled accent shape, you will need to rebuild those with the appropriate scale color (e.g. bg-yellow-400) or promote the accent to a *-bold variant in your theme.

New hover utilities

Two new CSS utilities replace the handful of raw hover:bg-* patterns that accumulated across components over time:

  • ui-state-hover applies the standard hover for list items, table rows, and menu items on white surfaces. Resolves to bg-hover text-foreground.
  • ui-state-hover-ghost applies a translucent hover for ghost buttons, tabs, and action bar buttons that need to adapt to whatever surface they sit on. Resolves to bg-current/10.

All first-party components now use these, so hover behavior is consistent across the library. If you built custom components that imitated Marigold's hover styling by copying Tailwind classes, prefer these utilities so you pick up future tweaks automatically.

Elevation utilities removed

The legacy util-surface-* utilities were deprecated in v17.0.0 and announced for removal. v18 finishes that migration. The separate elevation-layer utilities are gone in favor of the ui-surface utility combined with standard Tailwind shadow-elevation-* classes.

Migration:

Old utilityNew replacement
util-surface-sunkenRemoved. Use bg-background for the page base layer
util-surface-bodybg-background (no shadow needed)
util-surface-raisedui-surface shadow-elevation-raised
util-surface-overlayui-surface shadow-elevation-overlay
- <div className="util-surface-raised">
+ <div className="ui-surface shadow-elevation-raised">
    …
  </div>

The refreshed Elevation foundation page now walks through the three-tier shadow model (border, raised, overlay) and when to reach for each.

Retired <Multiselect>, migrate to <TagField>

<Multiselect> has been deprecated since v17.0.0 and is now gone. It was the only component pulling in react-select, and keeping it around meant shipping two different multi-select mental models. <TagField> has covered every real use case since its promotion to beta, so v18 removes the original.

- <Multiselect label="Genres" options={options} onChange={setValues} />
+ <TagField label="Genres">
+   <TagField.Option id="rock">Rock</TagField.Option>
+   <TagField.Option id="jazz">Jazz</TagField.Option>
+   <TagField.Option id="pop">Pop</TagField.Option>
+ </TagField>

The react-select dependency has been removed from @marigold/components, so bundle size drops for any app that wasn't already on <TagField>. See the TagField documentation for the full API, including controlled/uncontrolled selection, disabled keys, sections, and custom empty states.

<Tooltip> no longer accepts open

The open prop has been removed from <Tooltip>. Controlled visibility now lives on <Tooltip.Trigger> only, which is where the rest of the trigger state already was. The previous setup duplicated the prop across two components and bridged them through a React context, which made it easy to end up with a trigger that ignored the tooltip's open prop (or vice versa).

- <Tooltip open={isOpen} onOpenChange={setIsOpen}>
-   <Tooltip.Trigger>
-     <Button>Info</Button>
-   </Tooltip.Trigger>
-   <Tooltip.Content>Hello</Tooltip.Content>
- </Tooltip>
+ <Tooltip>
+   <Tooltip.Trigger open={isOpen} onOpenChange={setIsOpen}>
+     <Button>Info</Button>
+   </Tooltip.Trigger>
+   <Tooltip.Content>Hello</Tooltip.Content>
+ </Tooltip>

Uncontrolled usage is unchanged.

<Switch> layout and sizing rework

<Switch> now sits on the left of its label, matching <Checkbox> and <Radio>. The default track is smaller (16×28px, down from 24×40px) so the control visually weighs the same as a checkbox when the three appear in the same form. The size="large" prop is gone.

For the classic settings-page layout (label and description on the left, toggle anchored to the far right), there is a new variant="settings":

<Switch
  label="Email notifications"
  description="Receive a summary every Monday"
  variant="settings"
/>

<Switch> also now accepts a description prop (matching <Checkbox>) and passes name through to the underlying input so it works in plain HTML form submissions. The description is wired up with aria-describedby and aligns with the label via CSS subgrid, so it stays correct at any control size without hardcoded padding.

Custom theme migration (required)

<Switch> and <Checkbox> share a new internal BooleanField wrapper that renders descriptions and handles aria-describedby. If you maintain a custom theme, you must add a BooleanField component, or <Checkbox> / <Switch> with a description prop will throw at runtime:

Error: Component "BooleanField" is missing styles in the current theme.

Add the following to your theme:

import { cva } from '@marigold/system';

export const BooleanField = {
  container: cva({
    base: 'grid gap-x-2',
    variants: {
      variant: {
        default: 'grid-cols-[auto_1fr]',
        settings: 'grid-cols-[1fr_auto]',
      },
    },
    defaultVariants: { variant: 'default' },
  }),
  description: cva({
    base: 'mt-0.5',
    variants: {
      variant: {
        default: 'col-start-2',
        settings: 'col-start-1',
      },
    },
    defaultVariants: { variant: 'default' },
  }),
};

Then export it from your theme's component index.

The Checkbox and Switch container slots have also moved from flexbox to CSS grid with conditional subgrid (so the description column aligns under the label regardless of control size).

Theme-owned breakpoints

Breakpoint resolution is now driven by the theme. useSmallScreen and useResponsiveValue read theme.screens from the ThemeProvider context instead of the hardcoded values that used to live inside @marigold/system's defaultTheme. When the theme doesn't provide screens, the hooks fall back to Tailwind v4's --breakpoint-* custom properties.

For apps on @marigold/theme-rui, this is a no-op. The theme now exposes the same breakpoints (Tailwind v4 defaults) that the system used to ship. If you maintain a custom theme that doesn't extend theme-rui, add a screens map to it, or rely on the CSS fallback:

import type { Theme } from '@marigold/system';

export const myTheme: Theme = {
  name: 'my-theme',
  screens: {
    sm: '40rem',
    md: '48rem',
    lg: '64rem',
    xl: '80rem',
    '2xl': '96rem',
  },
  // ...
};

The motivation: breakpoints are a theming concern, not a system concern. Different products have different layout rhythms, and forcing every app through the same default made theme portability harder than it needed to be.

<AppLayout> now scrolls at the page level

<AppLayout> no longer owns an interior scroll container. The document (<html> / <body>) scrolls the whole page, the sidebar holds its position via position: sticky, and the top header stays pinned through <TopNavigation>'s own sticky positioning. Visually, app shells look the same. Behaviorally, almost everything that depends on "what is scrolling" gets simpler.

The reason this matters in real apps:

  • Mobile URL bar. Safari and Chrome on mobile only collapse the URL bar when the document scrolls. With an interior scroll container, you permanently lose ~8% of the screen. Page-level scroll gets it back.
  • Pull-to-refresh works again on mobile (interior scroll disables it).
  • Browser scroll restoration on back/forward only works reliably for the document, not interior containers. Interior scroll causes subtle "lost scroll position" bugs.
  • Cmd+F find-in-page scrolls the document, so matches outside the viewport now scroll into view correctly.
  • Anchor links (#section), iOS status-bar tap (scroll-to-top), and native keyboard navigation (PgUp, PgDn, Space, Home, End) all behave predictably.
  • IntersectionObserver with the default root, scroll-snap, position: sticky, and scroll-margin-top are all simpler when there is exactly one scroll container.

Migration. Two patterns break:

- const top = mainRef.current?.scrollTop;
+ const top = window.scrollY; // or document.documentElement.scrollTop
- <AppLayout.Main>
-   <div className="h-full">…</div>
- </AppLayout.Main>
+ <AppLayout.Main>
+   <div className="min-h-dvh">…</div>
+ </AppLayout.Main>

Anything that read mainRef.current.scrollTop (or attached a scroll listener to the main container) needs to read from window / document.documentElement instead. Styles that assumed a viewport-bounded main region (height: 100% on direct <AppLayout.Main> children, for example) are no longer constrained. Switch to min-h-dvh or drop the constraint altogether.

A small known trade-off: a pure app-shell look using position: sticky can flicker briefly during iOS Safari momentum scroll, and sticky elements may show a one-frame repaint when overlays close. Both are cosmetic, neither affects correctness, and the behavioral wins above are worth it.

<Inset> padding props renamed

<Inset>'s padding props are renamed to align with <Panel>'s API. Across the design system, space always means gap between children and p / px / py always mean inner padding. Previously space carried two different meanings depending on the component, which was a source of confusion in code review.

- <Inset space="related"><Content /></Inset>
+ <Inset p="related"><Content /></Inset>

- <Inset spaceX="loose" spaceY="related"><Content /></Inset>
+ <Inset px="loose" py="related"><Content /></Inset>

The discriminated union shape is unchanged: p is mutually exclusive with px / py. Token vocabularies are unchanged (InsetSpacingTokens for p, PaddingSpacingTokens for px / py).

<Card> refactored to compound component pattern

<Card>'s prop-based API (padding, space, etc.) is gone. Content is now composed through explicit sub-components, which makes the framing and the body responsibilities visible in the markup. A CardContext is set up by <Card>, so sub-components throw a clear error if they're rendered outside one.

<Card.Header> is a slot provider. Drop a <Title> and an optional <Description> straight inside it and the header wires up the heading level, the id, the accessible name, and the theme classes for you. A bare <Title> placed directly inside <Card> (with no Card.Header wrapper) is picked up by the root as well, so a title-only card can skip the header and still get the right padding and aria-labelledby.

- <Card>
-   <SomeContent />
- </Card>
+ <Card>
+   <Card.Header>
+     <Title>Payment method</Title>
+     <Description>Visa ending in 4242</Description>
+   </Card.Header>
+   <Card.Content>
+     <SomeContent />
+   </Card.Content>
+   <Card.Footer>
+     <Button>Edit</Button>
+   </Card.Footer>
+ </Card>

<Card> now renders an <article> landmark, labelled by its <Title> through aria-labelledby (or by an explicit aria-label). A new headingLevel prop (default 3) sets the underlying heading tag, so the card slots into the document outline correctly. Variant text color flows through a new --card-accent custom property, so master and admin cards pick up the matching accent automatically.

For media at the top of the card, use <Card.Media>. This is a breaking rename: the slot was previously <Card.Preview>. If you styled it through the data-card-preview selector or the preview theme slot key, move to data-card-media and the media slot key.

With <Panel> now covering page-level sections, <Card> is the right unit for repeating collection items (a stat tile, a session row, a payment method, a team card). The Panel docs page includes a side-by-side comparison.

<SelectList> API standardized

<SelectList> has been refined into a first-class form field for picking one or many items from a visible list of rich two-line rows. The component was never reached for in practice. The API was ambiguous, the visual treatment didn't distinguish it from <ListBox>, and it sat awkwardly between Radio and Checkbox groups and <Select>. v18 tightens the API, gives it a dedicated theme entry, and rewrites the docs around clear decision guidance.

The new shape:

  • Single-select rows render a visible radio circle, multi-select rows render a checkbox, so the selection mode is communicated at a glance.
  • Drop an <ActionMenu> or <IconButton> directly inside <SelectList.Option> for per-row actions. The component handles positioning, sizing, and styling automatically (one action per option).
  • onChange is strictly typed per selectionMode, matching the shape of <Select>.
  • <SelectList> ships its own theme entry with dedicated label, description, and action slots.

Breaking changes:

  • SelectList.Item → SelectList.Option. Matches Select.Option and the HTML <option> mental model.
  • SelectList.Action removed. Drop your <ActionMenu> or <IconButton> directly inside <SelectList.Option>.
  • Leading-image slot removed. Compose images inside <Text slot="label"> (or anywhere in children) as you see fit.
  • selectionMode="none" removed. <SelectList> is a form field; the default is now "single".
  • Custom theme migration required. If you maintain a custom theme, add a SelectList entry.
- <SelectList selectionMode="none">
-   <SelectList.Item id="free">
-     <SelectList.Action>
-       <IconButton aria-label="Info"><Info /></IconButton>
-     </SelectList.Action>
-     Free
-   </SelectList.Item>
- </SelectList>
+ <SelectList selectionMode="single">
+   <SelectList.Option id="free">
+     <Text slot="label">Free</Text>
+     <Text slot="description">For personal use</Text>
+     <IconButton aria-label="Info"><Info /></IconButton>
+   </SelectList.Option>
+ </SelectList>

See the rewritten SelectList documentation for the full new API, the decision table for picking between <SelectList> and lighter controls, and patterns for per-row actions and empty states.

@marigold/icons migrated to a lucide-react proxy

@marigold/icons is now a thin proxy over lucide-react plus 13 retained custom icons (event-domain shapes that don't have a Lucide equivalent, like DesignTicket and Stadium). Icons that did have a Lucide equivalent (Add, Seat, Deal, …) were dropped in favour of the Lucide name.

Migrating from the legacy icon set

The package name stays the same (@marigold/icons), so the import path does not change. What changes is the icon names and a small API tweak.

Names. Most legacy names map to a Lucide equivalent. Lucide uses PascalCase consistently, so a few common renames look like this:

- import { Add, Delete, Exclamation, Search } from '@marigold/icons';
+ import { Plus, Search, Trash2, TriangleAlert } from '@marigold/icons';

If you'd rather not touch call sites, alias on import:

- import { Search, Check, Add, Ticket, Seat, Deal } from '@marigold/icons';
+ import {
+   Search,
+   Check,
+   Plus as Add,
+   Ticket,
+   Armchair as Seat,
+   BadgePercent as Deal,
+ } from '@marigold/icons';

The full name mapping is included in the changelog.

Size prop. size is now serialized as a numeric attribute (width="24") rather than "24px". Pass size={20} exactly as before, or, if you were passing a string with a unit suffix, drop the suffix to match the documented form (size="24px" → size={24}).

Brand icons. Brand-specific icons such as DesignTicket, GiftCard, Facebook, and Stadium keep their existing names and stay in @marigold/icons. No change required.

Practical notes on the new package

  • Always import from @marigold/icons, not from lucide-react directly. The package re-exports the entire Lucide catalogue along with the custom icons, so a single import path covers both.
  • For coloring, prefer Tailwind text utilities (<TriangleAlert className="text-warning-accent" />). Lucide reads the color prop as a literal CSS value, so color="warning" won't resolve a theme token. Reserve color / fill / stroke for literal values like var(--color-…) or hex codes.
  • 11 of the 13 custom icons are filled silhouettes wrapped with a small helper that defaults fill and stroke to currentColor and couples the color, fill, and stroke props so they move together. Pass stroke explicitly to differentiate. Instagram and Twitter are stroke-based outlines and follow Lucide's standard color → stroke behaviour.
  • Icons get aria-hidden="true" automatically when rendered without children or an aria-* attribute. Provide aria-label for standalone meaningful icons.
  • The peer dependency narrows to react: >=19.0.0 (the custom icon wrappers rely on React 19's ref-as-prop).

<Select>, <ComboBox>, <Autocomplete> no longer accept width="fit"

The fit value is gone on these three components. Their listbox renders through a popover with virtualized content, and the react-aria Virtualizer controls item sizing independently of CSS layout. As a result, width="fit" never made it into the popover, which ended up clipping the dropdown contents.

- <Select label="Role" width="fit">
+ <Select label="Role" width="1/2">

Every other width value (fractions, fixed sizes, full) continues to work. The same change applies to <ComboBox> and <Autocomplete>.

Components

Slot-aware <Button> and <ButtonGroup>

<Button> is now slot-aware: it adapts to the button container it sits in, so you reach for <Button> everywhere instead of learning a second action component. A Marigold-owned ButtonContext carries variant, size, and disabled down to the buttons nested inside a container, and a local prop on a button always wins over what the container sets.

<ButtonGroup> is the new component for clustering related buttons. It owns an orientation-aware layout (a horizontal or vertical flex with a small gap) and cascades a secondary variant to its buttons by default, the same baseline a standalone <Button> already has.

<ButtonGroup>
  <Button>Cancel</Button>
  <Button variant="primary">Save</Button>
</ButtonGroup>

Slot-aware containers tune the cascade for their context. <Panel.Header> publishes a lower-emphasis ghost variant at small size and positions the buttons in its actions cell, so a labelled header action reads as chrome without you setting anything:

<Panel.Header>
  <Title>Organizer info</Title>
  <Button>Edit</Button>
</Panel.Header>

A button opts out of the cascade with slot={null}, and overlays (Popover, Modal, Tray, Drawer) reset the context at their content root, so a header or group cascade cannot leak through the portal into an overlay's own buttons.

<Panel>

<Panel> is a new compound component for page-level content sectioning. It sits between <AppLayout> and the content inside it. Where <Card> is meant for repeating collection items (a stat tile, a session row, a team card), <Panel> frames a full page section with a header, optional description, actions, body, and footer.

<Panel>
  <Panel.Header>
    <Title>Organizer info</Title>
    <Description>
      Shown on the event page and in confirmation emails.
    </Description>
    <Button aria-label="Edit">
      <Pencil />
    </Button>
  </Panel.Header>
  <Panel.Content>…</Panel.Content>
  <Panel.Footer>…</Panel.Footer>
</Panel>

<Panel.Header> is a single slot-configuration boundary: it publishes the heading level, ids, grid-area positioning, and the action size/variant cascade. Drop the slot-aware primitives (<Title>, <Description>, <Button>, <ButtonGroup>, <ActionMenu>, <LinkButton>) directly inside it — no wrapper sub-components needed.

Title-only Panels. A bare <Title> can sit as a direct child of <Panel> when the panel has only a title (no description, no actions). <Panel.Header> remains the layout wrapper for title plus description plus actions, but a title-only panel does not need it. Accessibility (aria-labelledby) and horizontal padding still resolve correctly.

<Panel>
  <Title>General settings</Title>
  <Panel.Content>…</Panel.Content>
</Panel>

Collapsible sections. <Panel.Collapsible> mirrors the header layout for sections that fold. The entire surface is a single click target. Title and description render as spans inside the trigger <button> (driven by the same slot-configuration pattern), and aria-labelledby and aria-describedby are wired up automatically. A reusable MorphCaret chevron animates via SVG path morphing and respects prefers-reduced-motion.

<Panel>
  <Panel.Header>
    <Title>Advanced settings</Title>
  </Panel.Header>
  <Panel.Collapsible>
    <Panel.CollapsibleHeader>
      <Title>Webhooks</Title>
      <Description>Outbound HTTP callbacks</Description>
    </Panel.CollapsibleHeader>
    <Panel.CollapsibleContent>…</Panel.CollapsibleContent>
  </Panel.Collapsible>
</Panel>

Edge-to-edge content. <Panel.Content> and <Panel.CollapsibleContent> both accept an inset prop using the shared semantic spacing tokens. Combined with the new collapsed token (below), you can render a <Table> or media block that bleeds out to the panel edges without hand-rolling negative margins.

<Panel> also extends HTMLAttributes, so standard attributes (id, data-*, event handlers) spread onto its root <section>, matching the <Card> API. The root always carries a valueless data-panel attribute too, which host stylesheets can target as a stable selector (for example :not(:has([data-panel]))) without depending on Tailwind utility classes.

The full API covering header actions, footer layouts, bleed content, danger-zone rows, controlled collapsible state, error states, and a direct Panel vs Card comparison lives on the new Panel documentation page.

Universal collapsed spacing token

A new collapsed token is available across every spacing-accepting prop in the system: <Stack> / <Inline> space, <Inset> p / px / py, and anywhere else that takes a spacing token. It resolves to zero.

<Stack space="collapsed">
  <Header />
  <Table />
</Stack>

<Inset p="collapsed">
  <EdgeToEdgeContent />
</Inset>

The use case: wrappers that need to render without adding any spacing, for example an edge-to-edge <Table> inside a <Panel>. Before, you had to drop out of the semantic spacing system entirely and use numeric overrides whenever this came up. The token covers it directly.

The name is deliberate. collapsed reads naturally in both gap and padding contexts (cf. CSS margin collapse). An earlier iteration used none, but that collided with Tailwind v4's leading-none, which resolves none through the shared --spacing-* scale.

<Select> renderValue prop

<Select> accepts a new renderValue prop for customizing the trigger contents. When provided, the callback receives the selected items and replaces the default trigger render. Useful when the trigger should look different from the option, for example an avatar plus name in the trigger and an avatar plus name plus role in the dropdown. The placeholder still renders when nothing is selected.

<Select label="Assignee" renderValue={items => <UserAvatar user={items[0]} />}>
  <Select.Option id="anna">
    <UserAvatar user={anna} />
    <Text slot="label">Anna</Text>
    <Text slot="description">Designer</Text>
  </Select.Option>
</Select>

<CheckboxGroup> and <RadioGroup> continuous click area

Spacing between items in <CheckboxGroup> and <RadioGroup> moves from a gap to per-item padding, so the whole space between items is clickable. Vertical items now meet the 24 px target-size minimum. Horizontal spacing keeps visual parity. Standalone <Checkbox> is unaffected.

The inner row layout also switches from items-center to items-start, so the icon stays on the first line when the label wraps. <Radio> labels now use leading-4 to match <Checkbox>, and the icon-to-label gap moves from an inline gap-[1ch] to the theme-driven gap-x-2 for parity with <Checkbox>.

<FieldBase> forwards validation props through as

When <FieldBase> renders through a React Aria Components element via as={RACComponent}, the validation props (isInvalid, isRequired, isDisabled) are now forwarded so the underlying RAC element receives them. Plain DOM elements continue to skip these props to avoid unknown attribute warnings.

Focus outline on virtualized listbox items

The virtualizer wrapper inside <Select>, <ComboBox>, and <Autocomplete> sets an inline z-index: 0 per item, which created a stacking context the focused option's outline could not escape. Adjacent wrappers painted on top in DOM order and clipped the outline, most visible when the next item was selected. Each virtualized listbox now lifts the wrapper containing the focused option above its siblings so the outline renders fully.

<TextValue> and <Description> for selection items

Items inside <Select>, <SelectList>, <ListBox>, <Menu>, <ComboBox>, and <Autocomplete> can now compose their content with the <TextValue> and <Description> primitives instead of hand-written <Text slot="label"> and <Text slot="description">. The primitives are drop-in replacements that render the same underlying text with the same slots, so accessibility wiring (including aria-describedby) stays identical.

  <Select.Option id="anna">
-   <Text slot="label">Anna</Text>
-   <Text slot="description">Designer</Text>
+   <TextValue>Anna</TextValue>
+   <Description>Designer</Description>
  </Select.Option>

<Menu.Item> gains first-class label and description theme slots and a two-column grid layout, so a menu item can render a description under its label the same way a <SelectList.Option> does. Plain-text and icon-plus-text menu items are unaffected.

If you maintain a custom theme, the Menu and ListBox theme entries now require label and description slot keys. Add them, or <Menu> and <ListBox> items will fail the theme type check.

Animated caret in <Accordion.Header>

<Accordion.Header> now uses the shared MorphCaret icon, the same one introduced for <Panel.Collapsible>. It animates between the closed and open states by morphing its SVG path and respects prefers-reduced-motion. The unused ChevronDown icon was removed.

Fixes

  • Wide content overflow in <AppLayout.Main>. Wide content (most visibly a <Select selectionMode="multiple"> with several long selected items) no longer pushes the main column past the viewport. The main grid item now sets min-w-0, so its track can shrink and children like a truncated Select trigger clip at the right place.
  • Responsive <SelectList>. Horizontal SelectList layouts flip to a vertical stack when their container is narrower than 40rem (about 640px).
  • SSR-safe MorphCaret. The caret now reads prefers-reduced-motion through a hook instead of sampling it at module load, removing a hydration mismatch between server and client.
  • <Panel> accessible name. Panel only sets aria-labelledby when a <Title> is actually present, so the <section> landmark no longer points at an empty id when a panel has no title.

Docs and examples

v18 ships alongside a substantial pass over the documentation site. None of these affect published packages, but if you follow the docs they are worth knowing about.

  • Layouts foundation restructured around <Panel> and the atomic primitives (<Stack>, <Inline>, <Inset>, <Split>, <Center>, <Tiles>, <Columns>). A new Choosing the right layout decision table replaces the previous two-tier "structural vs atomic" framing, and the wireframe hero is now an annotated SVG anatomy diagram that matches the Panel and Sidebar anatomies. See Layouts.
  • Elevation foundation rewritten around the three-tier shadow system (border, raised, overlay). Adds a surfaces section that explains bg-background, bg-surface, bg-muted, and ui-surface, interactive demos, per-tier Do/Don't guidance, and a migration table for the retired util-surface-* utilities. See Elevation.
  • New Panel documentation page: MDX page, anatomy diagrams (header + collapsible), and a full set of demos covering appearance, variants, header actions, collapsible sections (incl. controlled and error states), footers, bleed content, danger-zone rows, and a side-by-side Panel vs Card comparison.
  • App-shell example pages fleshed out. Analytics, Billing, General, Security, Teams, and Users are now real screens built from Marigold components. Panel is used for page-level sections and Card is reserved for repeating collection items (payment methods, active sessions, team cards, stat tiles).
  • Switch variant="settings" documented end to end. A Button-style variant table under ## Appearance summarises default and settings, the Settings and preferences section is rewritten with placement guidance, and a Callout reserves variant="settings" for auto-save surfaces. The reasoning lives in the docs: light-switch metaphor, role="switch" ARIA contract, and the conflict with explicit save semantics.
  • Settings form and event form pattern examples at /examples/settings-form and /examples/event-form. The settings form demonstrates tabbed navigation, independent section saves with <Panel>, collapsible advanced fields, and a danger zone with confirmation dialogs. The event form shows a single-submit form with multiple <Panel> sections. The forms pattern documentation now includes a Panels subsection that links to both demos.
  • Full data management pattern at /examples/filter. A new venue browsing example demonstrating multi-criteria filtering coordinated through URL state via nuqs. Includes a toolbar with search and filter controls, applied filter chips for active filter display, and a paginated data table all driven by the same URL state.
  • Iconography documentation consolidated. The former /foundations/icons and /components/content/icon pages now live together at /foundations/iconography, with principles, the icon catalog, and the engineering API in one place. A new Using icons in code section covers installation, importing, sizing, color (currentColor plus className is the recommended path, color is a literal CSS value), filled brand icons, and accessibility (auto aria-hidden).
  • ActionBar docs moved into the Collection section. <ActionBar> is the floating toolbar of bulk actions for the current selection in a collection, so its page now sits at /components/collection/actionbar alongside Table, Card, and Tag, reframed around the bulk-selection context with a Related section linking back to Table and Button.
  • Form documentation restructured. Three pages (Form Fields, Forms, Form Implementation) are consolidated into two: Form Fields as a foundation (anatomy, label, placeholder, help text, width, field states) and Forms as a pattern (layout together with validation, state management, react-hook-form, and async submission).
  • Documentation prose cleanup. Em-dash and en-dash punctuation and prose semicolons across the component, foundation, and pattern docs are rewritten into plain sentences for easier reading. Numeric ranges now read as "N to M", while example strings that show literal component output keep their en-dash.

Design

The default theme @marigold/theme-rui gets a small visual pass and a CSS architecture cleanup. Existing imports of theme.css and styles.css continue to work as documented.

Dialog adopts the shared panel pattern

<Dialog> now uses the same ui-panel-* utilities as <Drawer>, <Tray>, and <Sidebar>. Its actions gain a border-t divider as an interaction-zone marker, and its content picks up consistent vertical padding. The responsive button stacking specific to Dialog stays as it was, and Drawer, Tray, and Sidebar are visually unchanged.

Instant hover backgrounds

Hover background is dropped from the transition across ui-surface, Button, Tabs, Table, Sidebar, Calendar cells, SelectList, and ActionBar. Background flips on hover now happen instantly, which makes high-frequency controls feel snappier and brings primary and secondary buttons in line with the ghost and destructive variants that were already instant. Color, border, box-shadow, and transform transitions are preserved.

<Checkbox> and <Radio> painted with bg-surface

<Checkbox> and <Radio> controls now paint their inner area with bg-surface, so the controls stay visually distinct over containers that paint a non-default background, for example a hovered or selected <SelectList> row. <Radio> already used bg-surface (added during the design-token polish). This brings <Checkbox> into parity.

Transparent ui-scrollbar track

ui-scrollbar's track is now transparent so the themed scrollbar blends into whatever surface it sits on instead of showing a visible groove on dark or tinted backgrounds.

New preflight.css

preflight.css adds two peer-dependency rules on the real <html> / <body>. They support the new page-level scroll model (see <AppLayout> now scrolls at the page level):

  • html { scrollbar-gutter: stable } prevents a 1px reflow when @react-aria/overlays locks the page.
  • body { overflow-x: clip } keeps any off-screen react-aria portal (such as the live-announcer node mounted at top: -10000px; left: -10000px) from expanding the document's scrollable area. clip instead of hidden so position: sticky on descendants keeps working.

Body is intentionally not given position: relative. Doing so would make it the containing block for absolute overlays (Tooltip, Popover, Menu, Select dropdown), and react-aria's overlay positioning then double counts the page scroll offset when computing available space above the trigger. The result is overlays with a top placement flipping to bottom even when the viewport has plenty of headroom. The file's comment now warns that position, transform, contain, filter, backdrop-filter, or will-change: transform on <body> would reintroduce the bug.

Both entry points (theme.css and styles.css) ship these rules, with the prefixer excluding html / body so they reach the document root while the rest of the bundle stays scoped to [data-theme="rui"].

File layout split

The theme's CSS now ships as four files with unambiguous roles:

  • tokens.css: @plugin declaration plus every design token in one place.
  • preflight.css: the two peer-dependency rules above.
  • theme.css: Tailwind-native entry. Imports preflight.css + tokens.css + ui.css + variants.css and paints <body> directly. Use this when Marigold is your whole app.
  • styles.css: pre-compiled drop-in. Same base, but paints [data-theme="rui"] instead. Use this when Marigold lives on an island, or when you're not running Tailwind. Place data-theme="rui" on the wrapper that should be themed.

global.css (which had ambiguous semantics and was never released to consumers) is gone. Both tokens.css and preflight.css are now subpath exports if you want to import them directly.

Tokens at :root

postcss-prefix-selector previously rewrote @theme's output from :root, :host to [data-theme="rui"], [data-theme="rui"] :host, which meant any rule outside the scoped wrapper couldn't resolve var(--color-background) because the variables only existed inside [data-theme="rui"]. The prefixer now excludes :root, :host, and [data-theme="rui"] (with a quote-agnostic regex that survives Prettier round-trips) in addition to html / body. Design tokens are emitted globally. Utility classes remain scoped.

CSS exports declare a style condition

Tailwind v4's CSS resolver uses conditionNames: ["style"]. Bare .css export entries without a matching condition fail under strict resolvers, so every .css subpath now declares both style and default targets. The unused ./* JS catchall is removed.

Dependency updates

  • React 19 is now the required baseline (see Before you upgrade).
  • react-aria-components, @react-aria/*, @react-stately/*, @react-types/*, and @internationalized/* are bumped to their latest versions. Most consumers do not need to do anything. If you import directly from react-aria-components, re-read the react-aria changelog for the version range.
  • react-select is no longer a dependency. <Multiselect> was the only component pulling it in, and removing it shrinks the @marigold/components bundle for every app that had already migrated to <TagField>.

v18 is a clear break from the shape of v17, and that is on purpose. The charcoal palette, renamed tokens, retired <Multiselect>, cleaned-up surface utilities, and the move to page-level scroll in <AppLayout> all point toward the same goal: a design system that describes its intent clearly and doesn't carry duplicate mental models from earlier iterations.

There is more coming. The token work in particular is ongoing. Expect further refinements in v18.x as we use the new palette and naming in real product surfaces and learn where the edges still need to move. As always, we welcome your feedback.

Last update: 20 days ago

On this page

Breaking ChangesDesign tokens overhaulElevation utilities removedRetired <Multiselect>, migrate to <TagField><Tooltip> no longer accepts open<Switch> layout and sizing reworkTheme-owned breakpoints<AppLayout> now scrolls at the page level<Inset> padding props renamed<Card> refactored to compound component pattern<SelectList> API standardized@marigold/icons migrated to a lucide-react proxy<Select>, <ComboBox>, <Autocomplete> no longer accept width="fit"ComponentsSlot-aware <Button> and <ButtonGroup><Panel>Universal collapsed spacing token<Select> renderValue prop<CheckboxGroup> and <RadioGroup> continuous click area<FieldBase> forwards validation props through asFocus outline on virtualized listbox items<TextValue> and <Description> for selection itemsAnimated caret in <Accordion.Header>FixesDocs and examplesDesignDialog adopts the shared panel patternInstant hover backgrounds<Checkbox> and <Radio> painted with bg-surfaceTransparent ui-scrollbar trackNew preflight.cssFile layout splitTokens at :rootCSS exports declare a style conditionDependency updates