Migration

Upgrade to 3.0, and the token changes from 2.0 to 2.5.

Each section covers one upgrade, newest first, and lists only what needs an edit on your side. Everything else a release changes is in the changelog.

Upgrading to 3.0

3.0 removes what 2.x deprecated, namespaces every public custom property with --cffy-, and settles the component API. Work through the steps in order: the find-and-replace in step 2 expects step 1 to be done.

  1. Clear the 2.x deprecations
  2. Namespace the custom properties
  3. Update tonal colors
  4. Replace the removed styles
  5. Replace removed props, slots and components
  6. Rename event listeners
  7. Use the ids you pass
  8. Replace PrimeVue components (optional)

Steps 1–4 touch your stylesheets (@colorffy/css), steps 5–7 your templates (@colorffy/ui). Step 8 is for apps that still use PrimeVue, whose Colorffy skin 3.0 removes. Until you have finished, keep both packages on ^2.8, which still accepts every deprecated name and still styles PrimeVue.

Clear the 2.x deprecations

These names still work in 2.8, so you can make these edits and ship them before you upgrade.

Font sizes. The ordinal tokens and their classes are gone. Rename them to the t-shirt scale:

Removed token / class3.0 token3.0 classMax size
--fs-sm-500 / .fs-sm-500--cffy-fs-4xs.fs-4xs11px
--fs-sm-400 / .fs-sm-400--cffy-fs-3xs.fs-3xs12px
--fs-sm-300 / .fs-sm-300--cffy-fs-2xs.fs-2xs13px
--fs-sm-200 / .fs-sm-200--cffy-fs-xs.fs-xs14px
--fs-sm-100 / .fs-sm-100--cffy-fs-sm.fs-sm15px
--fs-600 / .fs-600--cffy-fs-base.fs-base16px
--fs-500 / .fs-500--cffy-fs-lg.fs-lg20px
--fs-400 / .fs-400--cffy-fs-xl.fs-xl24px
--fs-300 / .fs-300--cffy-fs-2xl.fs-2xl28px
--fs-200 / .fs-200--cffy-fs-3xl.fs-3xl32px
--fs-100 / .fs-100--cffy-fs-4xl.fs-4xl40px
--fs-xl-100 / .fs-xl-100--cffy-fs-5xl.fs-5xl~53px

On 2.8 the new tokens have no prefix yet (--fs-4xs); step 2 adds it. A t-shirt class also sets its paired line-height, which the legacy class did not, so text moved from .fs-500 to .fs-lg gets a 28px line box. Check tight layouts such as badges and table cells.

SCSS variables. $space-1, $space-2 and $space-3 are gone; use var(--cffy-space-16), var(--cffy-space-32) and var(--cffy-space-48). So are the color and font variables deprecated in 2.8 ($primary … $muted, $primary-colors, $font-*), and a @forward … with (…) that still sets one fails to compile. Drop them from the configuration and set the tokens instead:

// 2.x
@forward '@colorffy/css/scss/abstracts/variables' with (
  $primary: #6750a4,
  $font-primary: 'Inter', sans-serif
);
/* 3.0 */
:root {
  --cffy-color-brand-primary-500: #6750a4;
  --cffy-font-primary: 'Inter', sans-serif;
}

Namespace the custom properties

Every public custom property now starts with --cffy-, so Colorffy's names no longer collide with other libraries on the page. The semantic --theme-* tokens swap theme- for the namespace (--theme-primary-a10 → --cffy-primary-a10, --theme-surface-container → --cffy-surface-container), and every other public name gains it (--color-brand-primary-500 → --cffy-color-brand-primary-500, --space-16 → --cffy-space-16, --card-bg-color → --cffy-card-bg-color). In your stylesheets and inline styles, run these replacements in order:

  1. Replace --theme- with --cffy-.
  2. Replace this regular expression with --cffy-$1. It only matches names that are Colorffy's alone, so your own custom properties stay untouched:
    (?<![\w-])--((?:accordion|alert|anchor|avatar|badge|breadcrumb|btn|card|carousel|chip|code|dialog|divider|dots|dropdown|duration|element|fab|fade|footer|form|fs|fw|gradient|header|hero|icon|ie|input|iw|kbd|link|list|loading|menu|navbar|navigation|noise|popover|progress|pwa|segmented|shape|sidebar|skeleton|space|state|stepper|subheading|table|tabs|timeline|toggle|tooltip|vfs|vfw|color-brand|color-wheel|border-width|focus-ring)-|angle\b|carousel\b|h-fixed\b|w-fixed\b)
    
  3. Review --radius-*, --shadow-*, --ease-* and --font-primary / -secondary / -code / -icons by hand. Tailwind v4 and other libraries use the same names, so rename only the ones that set or read Colorffy's tokens.

These variables changed more than their prefix:

2.x3.0
--theme-nav-drawer-width (--cffy-nav-drawer-width after the replacement)--cffy-sidebar-width
--_drawer-active-color--cffy-sidebar-item-active-icon-color
--_progress-width (the fill of a hand-written .progress-bar)--cffy-progress-value

UiSidebar's width prop already sets the new name.

Private variables. Variables that start with --_ were never public API, and in 3.0 most of them are named after their public hook (--_card-gutter-x → --_card-padding-inline, --_drawer-* → --_sidebar-*, --_form-* → --_input-*). A rule that set an old name stops applying without an error. Search your stylesheets for --_ and set the component's public hook instead, which also survives future refactors:

/* 2.x */
.compact-card { --_card-gutter-x: var(--space-8); }

/* 3.0 */
.compact-card { --cffy-card-padding-inline: var(--cffy-space-8); }

Each component page lists its hooks under CSS variables.

Update tonal colors

Tinted text reads the container tokens. In dark mode a90 used to be a light tint, the text color for tinted surfaces. Every ramp now darkens step by step in both modes, so a90 is its darkest step, and that text role is --cffy-on-<color>-container. Where you read --cffy-<color>-a90 for text or icons on a tonal background, read the container token instead:

/* 2.x */
.tag-success { color: var(--theme-success-a90); }

/* 3.0 */
.tag-success { color: var(--cffy-on-success-container); }

Light mode renders the same either way. .text-*-emphasis and the library's components already read the new token.

Inverse on-colors are gone. --cffy-on-primary-inverse, --cffy-on-secondary-inverse and --cffy-on-accent-inverse were black in light mode and white in dark mode; read --cffy-on-background instead.

Custom intensities. --cffy-tonal-dark-intensity now defaults to 58% and --cffy-tonal-light-intensity to 8% (were 70% and 15%), because the ramps mix in oklab. A value you set yourself comes out darker in dark mode than it did in 2.x; start again from the new defaults.

Tonal mixins take the surface (SCSS). btn-tonal() and container-tonal() no longer compute the container from the base tone and an $isDark flag. Pass the container token, or build one with the tonal() function. btn-tonal() also takes the fill that hover and pressed mix toward:

// 2.x
@include m.btn-tonal(var(--theme-success-a10), var(--theme-success-a90), true);
@include m.container-tonal(var(--theme-success-a10), var(--theme-success-a90), true, false, badge);

// 3.0
@include m.btn-tonal(var(--cffy-success-container), var(--cffy-success-a10), var(--cffy-on-success-container));
@include m.container-tonal(var(--cffy-success-container), var(--cffy-on-success-container), false, badge);

Replace the removed styles

Some rules only ever served one app, or nothing at all, and are gone from @colorffy/css. Edit your own markup or build only if it used one:

RemovedUse instead
.menu-tools-container mega menu (.menu-tools, .menu-tabs, .menu-grid, .menu-cta)Nothing replaces it. Copy its rules from @colorffy/css@2.8 into your own stylesheet, or build the panel with UiPopover
.segmented-control (with .segmented-control-active and .active-grid)UiSegmentedControls, or .tab-segmented-control in CSS-only markup
.pwa-alert-container (.pwa-content, .pwa-content-text-wrapper, .pwa-actions)UiAlert with type="snackbar" and actions, or copy the rules from @colorffy/css@2.8
.ripple, .ripple-dark, .ripple-primary, .ripple-secondaryNothing; the ripple-color($color) mixin stays, so you can rebuild the effect

The PrimeVue skin is gone. @colorffy/css no longer styles PrimeVue's .p-multiselect, .p-select, .p-colorpicker, .p-toast, .p-confirm-dialog and .p-inputswitch, or sets its --p-* tokens. Move to the native fields in step 8. If some PrimeVue components have to stay for now, copy scss/components/_prime.scss from @colorffy/css@2.8 into your app's styles and import it after the framework.

Small helpers. Nothing in Colorffy UI used the rest, so copy any you rely on into your own stylesheet:

  • .hover-rotate and .hover-scale, .vr, and the pulse, glitch, shake-x, shake-y, fade-in-left and slide-in-down keyframes.
  • The slide, slide-inline, fade-inline, table and table-td-fade transitions; slide-block stays. The new transitions cover most of them: rename slide-inline to slide-start, fade-inline to slide-end and table to list.

Replace removed props, slots and components

ComponentRemovedUse instead
UiPopoverMenuuser, avatarUrl, avatarCustomClass, subtitleUiPopoverMenuUser in #header, see the header no longer renders identity
UiPopoverMenu#body-extra slotRender the whole body in #body (below)
UiPopoverMenuDefault slot<template #body>
UiSidebarDefault slot, UiSidebarHeader, UiSidebarBody, UiSidebarFooter#header, #body and #footer slots (below)
UiEmpty#button slot#action
UiInputFilelargesize="lg"
BaseSkeletonisThumbnailvariant="thumbnail"
ISegmentedTabpositionNothing; delete it, the indicator measures the active tab
UiButtonToggleGroupgroupLabelariaLabel; groupLabel never rendered
UiModalisHeadlessmode="headless"; isHeadless never applied
UiButtontooltipText, placementUiButtonTooltip; UiButton never showed a tooltip
UiPopoverMenuauthNothing; delete it, it was never read
UiDatatableskeletonCols, skeletonColExpanded, isExpandedNothing; the loading skeleton matches the visible columns

Adding rows to a menuItems menu. #body-extra appended content after the generated items. Render the items yourself in #body instead:

<!-- 2.x -->
<UiPopoverMenu :menu-items="items">
  <template #body-extra>
    <ThemeSwitch />
  </template>
</UiPopoverMenu>

<!-- 3.0 -->
<UiPopoverMenu>
  <template #body>
    <UiPopoverMenuGroup>
      <UiPopoverMenuItem v-for="item in items" :key="item.id" v-bind="item" />
    </UiPopoverMenuGroup>
    <ThemeSwitch />
  </template>
</UiPopoverMenu>

Sidebar regions. UiSidebar's slots render the header, body and footer themselves, the way UiCard and UiModal do. Move each region into its slot, and its custom-class to header-class, body-class or footer-class:

<!-- 2.x -->
<UiSidebar>
  <UiSidebarHeader custom-class="px-2">…</UiSidebarHeader>
  <UiSidebarBody>…</UiSidebarBody>
  <UiSidebarFooter>…</UiSidebarFooter>
</UiSidebar>

<!-- 3.0 -->
<UiSidebar header-class="px-2">
  <template #header>…</template>
  <template #body>…</template>
  <template #footer>…</template>
</UiSidebar>

Rename event listeners

Events lost their on prefix, and tab and step selection now emit the update: events v-model listens to:

Component2.x3.0
UiButton, UiButtonMenu, UiButtonTooltip@on-click@click
UiInputText, UiInputTextarea, UiInputSelect, UiInputCheck, UiInputRadio, UiInputRange, UiInputFile, UiInputOtp, UiInputPhoneNumber, UiInputColorPicker@on-update@update
UiModal, UiConfirmModal@on-close-dialog@close
UiButtonToggleGroup@on-option-click@option-click
UiTabs, UiSegmentedControls@update-active-tabv-model:active-tab, or @update:active-tab
UiStepper@update-active-stepv-model:active-step, or @update:active-step

A listener that only stored the new value becomes a v-model:

<!-- 2.x -->
<UiTabs :tabs="tabs" :active-tab="tab" @update-active-tab="tab = $event" />

<!-- 3.0 -->
<UiTabs v-model:active-tab="tab" :tabs="tabs" />

@click on a button already worked through the native event and keeps working; it now also receives the MouseEvent, and a disabled or loading button never emits it. Search your templates for @on-, v-on:on-, @update-active- and camelCase forms such as @onClick to find the rest.

Use the ids you pass

The id prop now lands unchanged on the component's main element. In 2.x some components rewrote it:

Component2.x rendered3.0 renders
UiButtonbutton-<id><id>
UiCardcard-<id><id>
UiAccordionaccordion-<id><id>
UiLinkTooltiplink-<id><id>
UiInputText, UiInputPhoneNumber<id>-input-text<id>
UiInputTextarea<id>-input-textarea<id>
UiInputSelect<id>-select<id>
UiInputRange<id>-input-range<id>
UiInputColorPicker<id>-input-color, and <id>-input-text on the text field<id>, and <id>-text on the text field

Update the selectors, <label for> attributes, # links, getElementById() calls and test queries that used the generated names:

// 2.x
document.getElementById('email-input-text')?.focus()

// 3.0
document.getElementById('email')?.focus()

Error messages keep their ids (<id>-error-0), so aria-describedby still points at them.

UiButtonMenu showed a "menu" tooltip unless you passed tooltip-text. It now shows none, so add tooltip-text where you want one:

<UiButtonMenu icon tooltip-text="Project actions">…</UiButtonMenu>

An icon-only menu button without tooltip-text or title is named "Menu" by the buttonMenu.ariaLabel label; set tooltip-text or title to give it a name that says what the menu does.

Replace PrimeVue components

This step is optional. 3.0 has native replacements for PrimeVue's Select, MultiSelect and DatePicker, the components the Colorffy skin styled, and for AutoComplete:

PrimeVueColorffy UI
Select (Dropdown before v4)UiInputCombobox
MultiSelectUiInputMultiSelect
AutoCompleteUiInputCombobox with remote, or UiInputMultiSelect for multiple
DatePicker (Calendar before v4)UiInputDate, or UiCalendar for inline

All of them are complete fields: they take label, errorMessages and the shared input props, so the FloatLabel or IftaLabel wrapper and the separate error message go away. Their lists and calendars open in the top layer, above dialogs and outside clipping containers, so appendTo has no equivalent.

Select

<!-- PrimeVue -->
<FloatLabel>
  <Select v-model="leadId" input-id="lead" :options="members" option-label="name" option-value="id" filter show-clear />
  <label for="lead">Project lead</label>
</FloatLabel>

<!-- Colorffy UI -->
<UiInputCombobox id="lead" v-model="leadId" label="Project lead" :options="members" option-label="name" option-value="id" clearable />
SelectUiInputCombobox
options, optionLabel, optionValue, optionDisabledThe same props
optionGroupLabel and optionGroupChildren (nested options)optionGroup, a field on flat options
filterFiltering is on by default; :filterable="false" for a plain select
showClearclearable
emptyMessage, emptyFilterMessageemptyText, or the #empty slot
invaliderrorMessages
variant="filled", sizevariant="filled", size
#option slot#option="{ option, selected, active }"
@change@update, or watch the v-model
editablefreeText
loadingloading
#value slot, virtual scrollingNot available yet

MultiSelect

The option props are the same as on Select. The rest maps like this:

MultiSelectUiInputMultiSelect
display="chip"The default: a removable chip per value
maxSelectedLabelsmaxChips
selectedItemsLabel ('{0} items selected')maxChipsLabel ('{count} selected')
selectionLimitmax
@change@add and @remove with the value, or @update
showToggleAllNot available

AutoComplete

PrimeVue asks for suggestions with @complete and shows suggestions; the Colorffy fields emit search with remote and show options.

AutoCompleteUiInputCombobox with remote
suggestionsoptions
@complete (event.query)@search (the text)
delay, minLengthsearchDelay, minSearchLength
Typed text kept as the valuefreeText
forceSelectionThe default without freeText
loading, loaderloading
dropdownThe toggle button is always there
optionLabel, #optionThe same
multipleUiInputMultiSelect with remote, plus freeText for typed values

DatePicker

The model is the change to watch: PrimeVue holds a range as [start, end], Colorffy as { start, end }, and only writes it once both ends are picked.

<!-- PrimeVue -->
<DatePicker v-model="period" selection-mode="range" :number-of-months="2" show-time :min-date="minDate" show-icon />

<!-- Colorffy UI -->
<UiInputDate id="period" v-model="period" label="Period" mode="range" time :min="minDate" />

When an API sends or expects [start, end], convert where you read it:

import type { IDateRange } from '@colorffy/ui'

const range = computed<IDateRange | null>({
  get: () => (period.value ? { start: period.value[0] ?? null, end: period.value[1] ?? null } : null),
  set: (value) => { period.value = value ? [value.start, value.end] : null },
})
DatePickerUiInputDate
selectionMode="single", "range"mode="single", "range"
selectionMode="multiple"mode="multiple", with a Date[] model
inlineUiCalendar
numberOfMonthsmonths (1 for a date, 2 for a range by default)
minDate, maxDatemin, max
disabledDates (an array)disabledDates, a function: date => booked.some(day => day.toDateString() === date.toDateString())
disabledDays (weekday numbers)disabledDates: date => [0, 6].includes(date.getDay())
firstDayOfWeek (locale config)weekStart, which defaults to the locale's
showTime, showSeconds, stepMinutetime, time="seconds", minuteStep
hourFormatThe browser's clock: the time fields are native <input type="time">
dateFormat ('mm/dd/yy')format (Intl.DateTimeFormat options); typed dates follow the locale's order
showIcon, manualInputAlways on: a calendar button and typed dates. trigger="button" gives a button without typing
showButtonBar (Today and Clear)presets with datePresets.today(), and clearable
touchUIAutomatic: a bottom sheet below 600px
showOtherMonthsshowOutsideDays on UiCalendar (shown, not pickable)
@date-select@update, or select on UiCalendar
view="month", view="year", timeOnlyNot available

Remove PrimeVue

Once no PrimeVue components are left, delete the PrimeVue plugin (app.use(PrimeVue), or @primevue/nuxt-module in nuxt.config), its theme setup and any copied _prime.scss, then uninstall primevue and @primevue/themes.

2.0 → 2.5

2.5 moved every spacing and font-size value onto design tokens. Nothing broke: the old names kept working as deprecated aliases until 3.0.

New spacing tokens

Spacing now uses a value-encoded numeric scale — the number is the pixel value at the default 16px root — derived from a single unit:

--space-unit: .25rem;
--space-4   /* 4px  */    --space-16  /* 16px */
--space-6   /* 6px  */    --space-20  /* 20px */
--space-8   /* 8px  */    --space-24  /* 24px */
--space-12  /* 12px */    --space-32  /* 32px */
--space-14  /* 14px */    --space-48  /* 48px */

All components consume these tokens, so you can retheme spacing at runtime:

/* denser UI everywhere, proportionally */
:root { --space-unit: .2rem; }

/* or adjust one step */
:root { --space-12: .7rem; }
The m-* / p-* / gap-*utility classes keep their own historical scale (1 = 0.25rem … 10 = 12rem) in 2.5 and are unaffected by these tokens.

Font sizes renamed to a t-shirt scale

The old ordinal names (--fs-100, where a smaller number meant a bigger size) are replaced by a t-shirt scale anchored at --fs-base. Values are unchanged — same fluid clamp()s, new names:

Deprecated (2.x alias)New tokenMax size
--fs-sm-500--fs-4xs11px
--fs-sm-400--fs-3xs12px
--fs-sm-300--fs-2xs13px
--fs-sm-200--fs-xs14px
--fs-sm-100--fs-sm15px
--fs-600--fs-base16px
--fs-500--fs-lg20px
--fs-400--fs-xl24px
--fs-300--fs-2xl28px
--fs-200--fs-3xl32px
--fs-100--fs-4xl40px
--fs-xl-100--fs-5xl~53px

Every size also gained a line-height companion using the double-dash suffix convention:

.intro {
  font-size: var(--fs-lg);
  line-height: var(--fs-lg--line-height); /* 1.4 */
}

New font-size utility classes

The new .fs-4xs … .fs-5xl classes set the size and its paired line-height. The legacy classes (.fs-100….fs-600, .fs-sm-100….fs-sm-500, .fs-xl-100) still work and still set font-size only, so existing markup renders identically.

<!-- 2.0 (still works, deprecated) -->
<p class="fs-500">Title</p>

<!-- 2.5 -->
<p class="fs-lg">Title</p>

Deprecated in 2.5 (removed in 3.0)

  • The 12 old font-size token names (aliases of the new tokens).
  • The 13 legacy .fs-* utility classes.
  • The $space-1 / $space-2 / $space-3 SCSS variables — use var(--space-16/32/48).

New in 2.5 (breaking for hand-written markup)

Tabs need an indicator element

.tabs-navigation no longer draws the active state as a border on the active link. The active link publishes an anchor-name and a dedicated element is positioned over it with CSS anchor positioning, so the list needs one extra child as its last item:

<ul class="tabs-navigation">
  <li class="tab-item">
    <button class="tab-link active">Overview</button>
  </li>
  <li class="tab-item">
    <button class="tab-link">Pricing</button>
  </li>

  <!-- required: the anchored indicator -->
  <li class="tab-indicator" aria-hidden="true" role="presentation"></li>
</ul>

Without that element the active tab shows no underline (or pill, with .tabs-pills) in browsers that support anchor positioning. Browsers that do not support it fall back to the old border/background on the active link, so they look the same either way.

This affects @colorffy/css consumers writing their own markup only — UiTabs in @colorffy/ui renders the element for you. The same pattern was already in place for .tab-segmented-control, whose .pill-indicator child is unchanged.

The popover menu header no longer renders identity

UiPopoverMenu's default header is now a title and the close button, with the close button pinned to the top right. Passing user, avatarUrl, avatarCustomClass or subtitle no longer draws the avatar block — move it into the header slot, where UiPopoverMenuUser takes the same user object and lays the avatar beside the name and email:

<!-- before -->
<UiPopoverMenu :user="user" avatar-custom-class="border" />

<!-- after -->
<UiPopoverMenu>
  <template #header>
    <UiPopoverMenuUser :user="user" avatar-class="border" />
  </template>
</UiPopoverMenu>

Those four props stay declared and deprecated through 2.x, and are removed in 3.0. The close button used to need an opt-in; it now renders by default and is turned off with :closable="false".

The popover menu renders as a native popover

In browsers that support both the Popover API and CSS anchor positioning, UiPopoverMenu now renders its panel as a native popover="auto": it moves to the top layer, anchors itself to its place in the navbar, and the browser handles click-outside and Esc dismissal (the component emits hideDropdown to keep isOpened in sync). Browsers missing either feature keep the previous class-based rendering, so nothing changes for them.

The API is unchanged — isOpened, @hide-dropdown, and any click-outside handler keep working in both branches. The one thing that can break is custom CSS that repositioned the panel: in the native branch .popover-menu sits in the top layer as position: fixed, so absolute-positioning overrides written against the old rendering stop applying there. Either target the native branch explicitly with .popover-menu[popover], or opt the component out with :native-popover="false" to force the class-based rendering everywhere.

Each item gets its own anchor-name and the indicator references the active one through a custom property (anchor(var(--_tabs-indicator-anchor) left)), which is what lets transition: inset animate it: transitions need a computed value change, and a fixed left: anchor(left) computes identically before and after, so it would snap instead. Up to 16 tabs animate (8 navigation bar items); beyond that the indicator still positions, it just jumps.

Chips split the container from the selected color

.btn-chip used to carry its selected look in one place. It is now two axes: the container the chip rests in, and the fill it takes once selected.

<!-- container: .btn-chip is the outline one, .chip-elevated the raised one -->
<button class="btn btn-chip">Outline</button>
<button class="btn btn-chip chip-elevated">Elevated</button>

<!-- color only lands together with .chip-active -->
<button class="btn btn-chip chip-active">Primary fill</button>
<button class="btn btn-chip chip-secondary chip-active">Secondary fill</button>
<button class="btn btn-chip chip-neutral chip-active">Neutral fill</button>

A color class on its own does nothing — an unselected chip is always just its container — and .chip-active with no color class takes the primary fill. .chip-elevated keeps its shadow through selection.

Two things change for existing markup:

  • .a40 and .a60 are gone. They tinted the active chip for side sheets and modals. Use .chip-neutral for the same low-emphasis fill, which now tracks --theme-surface-container-high on every surface.
  • .chip-active no longer forces a pill radius. It keeps the chip's own --_btn-radius, so selected chips match their unselected siblings. Add .btn-rounded where you still want the pill.

Card surfaces are static and interaction is its own class

A card's surface and its response to the pointer used to be the same decision. .card-elevated stepped its shadow on hover and press, and .card-selectable reset the border every variant had just set, so an outline card lost its outline the moment it became selectable.

The two axes are now separate: .card-outline, .card-pane and .card-elevated only describe the surface, and .card-selectable or .card-link carries every hover, press and focus state.

<!-- surface only: no hover, no press -->
<div class="card card-elevated">…</div>

<!-- interactive: the surface answers the pointer, the shadow steps up -->
<a class="card card-elevated card-link" href="…">…</a>
<div class="card card-elevated card-selectable">…</div>

Two things change for existing markup:

  • A plain .card-elevated no longer reacts to hover. It shows --shadow-sm and holds it. Add .card-selectable for a card the user picks, or .card-link (to/href on UiCard) for one that navigates, to get the sm → md → lg escalation back.
  • A card carries one surface class. They all set --_card-bg-color or --_card-shadow, so card-outline card-pane was only ever the last one to win. Drop the extra class rather than relying on the order.
Copyright © 2026