Frappe UIFrappe UI

Migration from v0

A guide for moving an existing app onto frappe-ui v1. Work through one component family at a time. Each section opens with a before/after table. For the full change list see the changelog; for the rationale behind each API see the v1 release specs.

After each pass, grep for the old prop or slot name to catch anything missed, then test the flows you touched. Type-checking won't catch focus, slot renames, or visual regressions.

What this guide covers

Every silent break has a before/after here: old code that still runs, still type-checks, and behaves differently. Vue drops an unknown prop or slot without a word, so those are the ones that reach production. Each is marked.

Loud breaks — an import that stops resolving, a type that no longer exists — are listed in the changelog and only appear here when the replacement needs explaining. If your build already names the file and the line, the changelog is the faster read.

Run the codemod linked from each relevant section. The tools cover Tailwind token renames (tokens-v2, see Tokens), shortcut config (shortcuts-v1, see The shortcuts codemod), destination prop renames (destinations-v1, see Destinations), overlay and picker controls (overlays-v1), navigation props and tab state (navigation-v1), and the EditorFixedMenu prop rename (editor-v1, see Editor), base component prop normalization (base-props-v1, see Base component props), and List row hooks and slot names (list-v1, see List family), and the FrappeUI plugin's resources option (data-v1, see the plugin), and the Tailwind preset path plus the Vite plugin's lucideIcons option (packaging-v1, see Packaging and tokens). The tools report ambiguous dynamic syntax for manual review instead of guessing.

Sections

Requirements

v1 requires Node >=20.19.0 (package.json engines). The 0.1.x line declared no engines field at all, so this is a new floor rather than a raised one — a Node 18 image that built v0 fine now fails to install.

Vue and vue-router are unchanged: vue >=3.5.0 and vue-router ^4.1.6 (package.json peerDependencies). An app already on v0 needs no Vue bump.

Tailwind stays on v3. frappe-ui/tailwind is a v3 preset and frappe-ui declares no tailwindcss peer dependency, so a v4 project installs cleanly and then fails at build time. See the Tailwind page.

v1 also depends on @vueuse/core ^14.1.0, up from ^10.4.1 in the 0.1.x line. VueUse 14 requires Vue ^3.5.0, which v1 already requires. If your app depends on @vueuse/core directly, move it to ^14 as well. Two major ranges in one app install two copies of the library, and a resolve.dedupe entry for @vueuse/core then collapses them onto whichever copy wins. That breaks the components which expect the newer one.

Run npx destinations-v1 . to migrate statically named component props imported from frappe-ui. It covers the first three renames below, including bound and shorthand props:

  • Replace component router props named to with route. Keep to only inside the route object itself.
  • Replace Button link with href for external URLs.
  • Replace PageHeader back to with fallbackRoute.
  • Replace TabButton tooltip with app-owned help UI and convert non-string labels to strings.

The codemod deliberately leaves globally registered components, JavaScript and TypeScript data objects, render functions, and v-bind="object" spreads alone. Review those shapes by hand.

Dialog

The options blob is flattened into top-level props. See the Dialog component page for the full API.

BeforeAfter
v-model="show"v-model:open="show"
:options="{ title, size, actions }"title / size / :actions
disableOutsideClickToClose:dismissible="false"
<template #body-content>default slot
<template #body-main>default slot
<template #body-title><template #title>
<template #body-header><template #title> (no direct replacement)
<template #body>bare prop + default slot
onClick: (close) => …onClick: ({ close }) => …
:icon="{ appearance: 'warning' }"icon="…" + theme="amber"
dialogRef.close()v-model:open / close slot prop
manual focus hacks / v-focusautofocus attr on a descendant

Most of this table is silent: Vue drops an unknown prop or slot with no error, so the dialog renders with no title, no actions, or an empty body, and a leftover :disable-outside-click-to-close quietly becomes dismissible. Two rows are loud instead — onClick: (close) => close() throws TypeError: close is not a function, and a template-ref .close() throws the same way. v-model itself still works (modelValue is kept as a second binding), but open is canonical and wins when both are bound.

vue
<!-- Before -->
<Dialog :options="{ title: 'Edit Item' }" v-model="show">
  <template #body-content>
    <FormControl label="Name" v-model="item.name" />
  </template>
  <template #actions>
    <Button variant="solid" @click="save">Save</Button>
  </template>
</Dialog>

<!-- After -->
<Dialog title="Edit Item" v-model:open="show">
  <FormControl label="Name" v-model="item.name" />
  <template #actions>
    <Button variant="solid" @click="save">Save</Button>
  </template>
</Dialog>

For reactive :options objects, spread them: <Dialog v-bind="opts || {}" />. For the imperative API, use dialog.confirm / dialog.danger / dialog.prompt from frappe-ui (callback-based: onConfirm resolves to close, throws to stay open) and wrap your app root in <FrappeUIProvider>.

The icon object is replaced by icon + theme

icon takes a lucide-* class name or a Vue component. The tone moves to its own theme prop, so the two decisions are separate and an icon component is possible at all.

vue
<!-- Before -->
<Dialog :icon="{ name: 'lucide-alert-triangle', theme: 'red' }" ... />

<!-- After -->
<Dialog icon="lucide-alert-triangle" theme="red" ... />
<Dialog :icon="AlertTriangleIcon" theme="red" ... />

The same two keys move on dialog.confirm, dialog.danger and dialog.prompt, which already had a top-level theme:

js
// Before
dialog.confirm({
  title: 'Delete',
  icon: { name: 'lucide-trash', theme: 'red' },
})

// After
dialog.confirm({ title: 'Delete', icon: 'lucide-trash', theme: 'red' })

Loud in dev, silent in production. An object passed to icon now renders an empty icon badge: the circle paints in the neutral tone with no glyph in it. A development build warns once per component and prop:

[frappe-ui] Dialog.icon received a plain object (keys: name, theme). The
{ name, theme } icon object was removed in 1.0.0. Pass a lucide-* string or a
component, and set `theme` at the top level. The icon renders empty.

The theme inside the old object is ignored, so pass theme at the top level. The DialogIcon type is gone (loud in TypeScript). Coming from v0, the older appearance key went the same way: map warning → amber, info → blue, danger → red, success → green and pass it as theme.

DialogAction is ImperativeDialogAction for dialog.*

Two action types existed under one name. The component's actions prop takes DialogAction, whose onClick receives { close }. The imperative helpers take a different shape, whose onClick receives { close, setError } and is awaited. That one is exported as ImperativeDialogAction.

ts
// Before: the name resolved to the component's type, which does not match
import type { DialogAction } from 'frappe-ui'
const actions: DialogAction[] = [{ label: 'Delete', onClick: async () => {} }]
dialog.confirm({ title: 'Delete', actions })

// After
import type { ImperativeDialogAction } from 'frappe-ui'
const actions: ImperativeDialogAction[] = [
  { label: 'Delete', onClick: async () => {} },
]

Types only; nothing changes at runtime.

theme: 'yellow'theme: 'amber'

The warning tone is amber, matching Alert, SidebarCard, Badge and Avatar. Dialog was the last component spelling it yellow, and it already rendered that value with the amber tokens — only the word changes, not the color.

BeforeAfter
theme="yellow"theme="amber"
dialog.confirm({ theme: 'yellow' })dialog.confirm({ theme: 'amber' })

This is a silent break for JavaScript call sites: yellow is no longer a key in the tone maps, so the icon renders with no tone and nothing throws. TypeScript call sites get a union error.

A template ref no longer exposes close()

vue
<!-- Before -->
<Dialog ref="dialogRef" v-model="show" />
<script setup>
dialogRef.value.close()
</script>

<!-- After -->
<Dialog v-model:open="show" />
<script setup>
show.value = false
</script>

Dialog exposes nothing on its template ref (ADR-0012); calling .close() now throws. Drive open through v-model:open, or use the close slot prop from inside #default / #actions.

DatePicker / TimePicker family

Covers DatePicker, DateRangePicker, DateTimePicker, and TimePicker. They share the popover-trigger vocabulary. Every removed prop and slot below is deleted, not aliased — and nothing warns at the tag: an unknown prop lands as an inert attribute, a renamed slot stops rendering. grep for each old name after upgrading.

BeforeAfter
:value propv-model
placement="bottom-start"side + align + offset
:autoClose:keepOpen (inverted)
allowCustom / picker-level readonlytypeable
inputClassclass
minTime/maxTime (TimePicker), minDateTime/maxDateTime (DateTimePicker)min / max
#target (DatePicker, DateRangePicker, DateTimePicker)#trigger — TimePicker has neither
TimePicker.scrollModenothing — list is always centered
TimePicker.use12Hourformat="h:mm A"

@change still fires alongside @update:modelValue — it wasn't deprecated and doesn't need replacing.

Most of the table above is a silent break: an old prop name that's no longer in the component's types lands as an inert extra attribute (or, for min/max aliases, the constraint just stops being enforced) instead of throwing. TypeScript callers get a compile error instead. #target is the one slot case — content in a leftover <template #target> silently stops rendering.

Trigger slot props

#trigger, #prefix, #suffix, and #actions receive open, setOpen, close, and disabled, alongside their picker-specific fields. TimePicker's #suffix follows. close() is shorthand for setOpen(false).

BeforeAfter
isOpenopen
togglePopoversetOpen

displayLabel and inputValue are unchanged. Replace toggle() with setOpen(!open) and toggle(value) with setOpen(value).

vue
<!-- Before -->
<template #trigger="{ togglePopover, isOpen }">
  <Button :class="isOpen && 'ring-2'" @click="togglePopover" />
</template>

<!-- After -->
<template #trigger="{ open, setOpen }">
  <Button :class="open && 'ring-2'" @click="setOpen(!open)" />
</template>

Behavior changes that apply even if you don't touch your code:

  • DateRangePicker emits a [from, to] tuple. Update handlers that called .split(',') on the value.
  • DateRangePicker.modelValue is string[] on the way in too. A stored v0 "from,to" string is read positionally, so the picker silently opens with nothing selected. Convert stored values with .split(',') before binding.
  • DateTimePicker no longer auto-closes on date click. Close from @update:modelValue or add an #actions Apply button.
  • The popover footer and auto Clear button were removed. Render an explicit Clear inside #actions if you relied on it.
  • DateRangePicker.clearable now defaults to true, and nothing on DateRangePicker reads it — emptying the input always clears the range. DatePicker and DateTimePicker still honour :clearable="false".
  • useDatePicker and its helpers (getDate, getDatesAfter, getDaysInMonth, isLeapYear) are deleted — the import fails. Nothing in the picker components used them; drop the import.

TimePicker emits: open, close, input-invalid, invalid-change

update:open carries the open and the close, with the state in the payload. input-invalid and invalid-change are gone with nothing in their place: typed text that does not parse reverts to the last valid value, which the user sees.

vue
<!-- Before -->
<TimePicker
  @open="onOpen"
  @close="onClose"
  @input-invalid="showHint"
  @invalid-change="setInvalid"
/>

<!-- After -->
<TimePicker @update:open="(open) => (open ? onOpen() : onClose())" />

A leftover @open is silent in JavaScript: it lands as an inert listener and never fires. TypeScript reports it, because TimePickerEmits is narrowed (and now exported). Variant on TimePicker is an alias of the shared InputVariant, not a second scale.

DateRangePicker v-model is DateRangeValue

The prop was string[], which let a one-element array in. Both sides are DateRangeValue now — [from, to] or []. TypeScript reports a ref<string[]> bound to the model.

ts
// Before
const range = ref<string[]>([])

// After
import type { DateRangeValue } from 'frappe-ui'
const range = ref<DateRangeValue>([])

Picker template refs, styling hooks and ARIA

All four pickers expose { open, close, focus }. They used to expose open() alone. open() is a no-op while the picker is disabled.

vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const picker = useTemplateRef('picker')
</script>

<template>
  <DatePicker ref="picker" v-model="date" />
  <Button label="Pick a date" @click="picker?.open()" />
</template>

Additive, and worth knowing if you style or test the pickers: the chevron carries data-slot="chevron", and the <input> carries role="combobox", aria-haspopup (dialog on the date pickers, listbox on TimePicker) and aria-expanded. The <input> keeps data-slot="control"trigger names the selection family's box only.

MonthPicker

MonthPicker is deleted — the import fails.

Its model was one string holding both parts, "<Month> <Year>" (for example "January 2026"), written by a popover that toggled between a month grid and a year grid. Nothing in v1 reproduces that, so pick the replacement that matches what your code reads off the value:

  • Month and year: use DatePicker and format the value yourself, or pair two Selects.
  • Month only: use Select with month options.
vue
<!-- Before -->
<MonthPicker v-model="month" />
<!-- month === 'January 2026' -->

<!-- After -->
<Select
  v-model="month"
  :options="[
    { label: 'January', value: '01' },
    { label: 'February', value: '02' },
    // ...
  ]"
/>
<!-- month === '01' — the year is no longer part of the value -->

Selection family (Dropdown / Select / Combobox / MultiSelect)

Upgrade all three pickers together. They share an option shape and a slot vocabulary, and most apps use more than one.

Nothing here was deleted for an alias — the removed props, option keys and slot props are gone outright. Most fail quietly; see each subsection.

Shared

BeforeAfter
Dropdown { group, items }{ group, options }
#option slot#item-label, plus #item-prefix for icons
option item slot propitem
clearAll slot propclear
chevron / trailing content#suffix slot (replaces the chevron)

Option values are string | number everywhere. Select no longer accepts bigint or object values.

Select

BeforeAfter
displayValue trigger slot propselectedOption.label
data-slot="trigger-value"nothing — it marked an invisible element used to measure
empty value undefinednull, the same as Combobox

The empty value is a silent break. Select emitted undefined and Combobox emitted null, so one single-value family had two answers for "nothing selected". Both are null now.

js
// Before
watch(value, (v) => {
  if (v === undefined) reset()
})

// After
watch(value, (v) => {
  if (v === null) reset()
})

clear() and the clear slot prop both write null. MultiSelect keeps [], because an empty array is what its consumers iterate. An empty string is still a real value, so a "None" row with value: '' round-trips unchanged.

Neither component emits anything on mount, so a model that starts as undefined stays undefined until the user picks an option or clears one. Both read undefined as "nothing selected", so the placeholder renders either way. Initialize the ref with null if the value is compared, sent to the server, or watched for the empty case.

SelectionOption and SelectionGroup are exported from the root, so a wrapper around any of the three can name its option shape once. The component-specific types stay.

Combobox

BeforeAfter
slotName on custom optionsslot, which dispatches to #item-<slot>
searchTerm in the custom-option contextquery
input emit@update:query
render on optionsslots
placement, ComboboxPlacementside + align
allowCustomValuetype: 'custom' option + condition
reset() on a template refclear()
SimpleOption, GroupedOption, SelectableOption, CustomOptionthe Combobox-prefixed names

MultiSelect

BeforeAfter
compareFn propnothing — an option is selected when its value is in modelValue
displayValue slot propsummary on #summary, or selectedOptions
toggleOpen slot propsetOpen(boolean)
BeforeAfter
placement prop, DropdownPlacement typealign
{ group, items }{ group, options }
component: option rowsslots: { item: fn }
DropdownExposed typenothing — it described a template ref surface that never existed; use v-model:open or the close slot prop

All three behavioral removals are silent in plain-JS apps — the old code still runs and renders wrong instead of failing — so check each one. TypeScript callers get errors instead: items and component stay in the option types as never, and placement is gone from DropdownProps altogether. A dev-mode console warning also fires when placement, items or component reaches the menu at runtime.

placement is ignored now. The menu falls back to align="start", so a right-aligned menu quietly moves left:

vue
<!-- Before -->
<Dropdown :options="options" placement="right" />
<Dropdown :options="options" placement="center" />

<!-- After -->
<Dropdown :options="options" align="end" />
<Dropdown :options="options" align="center" />

A { group, items } entry disappears — the group resolves to zero options and is dropped, leaving the rest of the menu intact:

ts
// Before
const actions = [
  { group: 'Edit', items: [{ label: 'Rename', onClick: rename }] },
]

// After
const actions = [
  { group: 'Edit', options: [{ label: 'Rename', onClick: rename }] },
]

A component: row renders as a plain action row using its label, which for most of these rows is empty:

ts
// Before
{ component: h(Button, { theme: 'red' }, () => 'Delete') }

// After
{
  label: 'Delete',
  slots: {
    item: () => h(Button, { theme: 'red' }, () => 'Delete'),
  },
}

These apply identically to ContextMenu, which shares the option shape (ContextMenuComponentOption is removed with DropdownComponentOption).

Custom rows

Select and MultiSelect lost #option; Combobox and MultiSelect lost render and slotName. They were the same idea — hand the whole row to the consumer. All three are replaced by region slots on a row the component owns.

vue
<!-- Before: one slot for the whole label area -->
<Select v-model="chartType" :options="options">
  <template #option="{ option }">
    <div class="flex items-center gap-2">
      <component :is="option.icon" class="size-4" />
      <span>{{ option.label }}</span>
    </div>
  </template>
</Select>

<!-- After: one slot per region -->
<Select v-model="chartType" :options="options">
  <template #item-prefix="{ item }">
    <component :is="item.icon" class="size-4" />
  </template>
  <template #item-label="{ item }">{{ item.label }}</template>
</Select>

An icon is rendered from option.icon automatically now, so the common case needs no slot at all — set icon and drop both templates.

Combobox's render moves the same way: the function form becomes slots.item, and the object form maps to slots key for key. Use these for lists built in JavaScript, where no template is in reach:

ts
const users = fetchedUsers.map((user) => ({
  label: user.name,
  value: user.id,
  slots: {
    prefix: ({ item }) => h(Avatar, { image: item.image, class: 'size-4' }),
  },
}))

Full-row takeover is still there — slots.item, or the #item template slot.

Custom options on Combobox

Two renames land on the same option object:

ts
// Before
{
  type: 'custom',
  key: 'create-new',
  slotName: 'create-new',
  onClick: ({ searchTerm }) => createItem(searchTerm),
  condition: ({ searchTerm }) => Boolean(searchTerm),
}

// After
{
  type: 'custom',
  key: 'create-new',
  slot: 'create-new',
  onClick: ({ query }) => createItem(query),
  condition: ({ query }) => Boolean(query),
}

The slot the row lands in is renamed with it. #create-new becomes #item-create-new, and it receives { item, query, selected } instead of { option, searchTerm }. onClick and condition keep their names.

If you used allowCustomValue

Combobox had a prop that accepted the typed text as the value and drew a built-in Create "…" row. It is gone. It could not do anything a custom row cannot, and it hardcoded the row — no way to change the label, add an icon, or say when it appears.

Build the row instead. It commits on click and on Enter, because Enter picks the highlighted row:

vue
<script setup>
const value = ref('')
const people = ref(['John Doe', 'Jane Doe'])

const options = computed(() => [
  ...people.value.map((p) => ({ label: p, value: p })),
  {
    type: 'custom',
    key: 'create',
    label: 'Create',
    slot: 'create',
    condition: ({ query }) =>
      Boolean(query.trim()) && !people.value.includes(query.trim()),
    onClick: ({ query }) => {
      value.value = query.trim()
    },
  },
])
</script>

<template>
  <Combobox v-model="value" :options="options">
    <template #item-create="{ query }">Create "{{ query }}"</template>
  </Combobox>
</template>

A modelValue that matches no option is kept regardless — the trigger falls back to the raw string — so nothing else changes.

dialog.prompt's allowCreate field option is unaffected; it now builds this row internally.

If you filter on the server

Combobox and MultiSelect filter their options in the browser by default. When the options already come back from a search endpoint, that filters them a second time and drops fuzzy, ranked, or id-based matches. Pass :filterable="false" to turn it off. Apps that forked the component for this reason can move back.

For the removed Autocomplete, see Autocomplete (removed).

Popover / HoverCard / Tooltip

The v0 Popover API is removed in 1.0.0. Nothing is aliased and nothing warns — Vue drops an unknown prop or slot without complaining, so a missed call site renders a popover with no trigger, or an empty one. Check every <Popover> in your app.

BeforeAfter
#target slot#trigger — reka wires the click, so drop your own click handler
#body slot#default + bare prop (renders without the panel shell)
#body-main slot#default
togglePopover / updatePosition slot propssetOpen (updatePosition is gone — reka repositions on its own)
placement="bottom-start"side="bottom" + align="start" (a bare side like placement="bottom" maps to align="center")
show / v-model:showopen / v-model:open
update:show emitupdate:open
hideOnBlurdismissible
matchTargetWidthmatchTriggerWidth
trigger="hover" (+ hoverDelay / leaveDelay)the HoverCard component
popoverClassdata-slot CSS hooks
transition="default"built-in motion — delete the prop
PopoverPlacement typePopoverSide + PopoverAlign
PopoverLegacySlotProps typePopoverSlotProps
NestedPopoverPopoverloud: the import fails, so the build names every call site. It never nested, and it was the last @popperjs/core consumer

Two more changes have no prop to grep for. v0 always set the panel's min-width to the trigger's width; v1 does it only under matchTriggerWidth, so a panel that leaned on that now shrinks to its content. And the panel no longer teleports into a #frappeui-popper-root div — reka portals it to body, or to the host's portal target. Delete any CSS or document.querySelector aimed at that id; nothing creates it now.

Trigger and content slots

#target did not wire anything — you called togglePopover yourself. #trigger renders through reka's PopoverTrigger as-child, which brings the click handler, keyboard support and aria-expanded with it. Keeping your click handler makes the popover toggle twice and stay shut.

vue
<!-- Before -->
<Popover placement="bottom-end">
  <template #target="{ togglePopover }">
    <Button label="Filter" @click="togglePopover" />
  </template>
  <template #body-main="{ close }">
    <FilterPanel @done="close" />
  </template>
</Popover>

<!-- After -->
<Popover side="bottom" align="end">
  <template #trigger>
    <Button label="Filter" />
  </template>
  <template #default="{ close }">
    <FilterPanel @done="close" />
  </template>
</Popover>

#body rendered outside the panel shell, so it maps to #default plusbare — without bare your content ends up inside a second panel.

vue
<!-- Before -->
<Popover>
  <template #body><EmojiPicker /></template>
</Popover>

<!-- After -->
<Popover bare>
  <EmojiPicker />
</Popover>

Driving the popover yourself

If your trigger needs custom timing (a delayed open, a drag that must not open it), bind open and accept only closes, so the trigger's own toggle cannot open it behind your back:

vue
<Popover :open="isOpen" @update:open="(value) => !value && (isOpen = false)">
  <template #trigger>
    <div @click="onClick">…</div>
  </template>
</Popover>

Slot props

#trigger and #default receive { open, setOpen, close }.

BeforeAfter
isOpenopen
open (a method to call)setOpen(true), or nothing — see below
togglePopoversetOpen
updatePositiongone; reka repositions on its own

open is now the boolean state, which is what it already means on Dropdown, Select, MultiSelect, HoverCard and Sidebar. It used to be a method on Popover alone.

This one is silent and worth grepping for: a destructured isOpen becomes undefined, so a class bound to it stops applying with no error.

vue
<!-- Before -->
<template #trigger="{ isOpen }">
  <Button :class="isOpen && 'ring-2'" label="Filter" />
</template>

<!-- After -->
<template #trigger="{ open }">
  <Button :class="open && 'ring-2'" label="Filter" />
</template>

Most triggers need nothing at all — #trigger wires its own click, so the open() method it used to hand out had no callers. setOpen is there for the cases that drive it by hand.

Attributes are not inherited

<Popover class="…"> and <Popover :style="…"> used to land on a wrapper the legacy #target rendered. #trigger is as-child and renders no wrapper, so those attributes now go nowhere. Move them onto the element inside #trigger.

Hover panels

Hover-driven panels move to the HoverCard component. hoverDelay and leaveDelay now use milliseconds; change 0.5 to 500 and 0.3 to 300. Tooltip and TooltipProvider delays use the same unit. HoverCard keeps its 300ms default.

Tooltip

BeforeAfter
hoverDelay="0.5"hoverDelay="500" (milliseconds)
skipDelay="0.3"skipDelay="300" on TooltipProvider (milliseconds)
placement="right"side="right"
arrowClass[data-slot="arrow"] CSS, or offset to shift the bubble
#body#content (add bare if the content owns its surface)

All three are silent — the tooltip keeps working, it just points the wrong way, loses the styling, or comes up empty. arrowClass was documented as the arrow's fill, but was mostly used to nudge the bubble's position; offset does that directly.

#default is still the trigger. That is deliberate and is not changing.

vue
<!-- Before -->
<Tooltip text="Preview" placement="bottom" arrow-class="mb-3">
  <Button label="Preview" />
</Tooltip>

<!-- After -->
<Tooltip text="Preview" side="bottom" :offset="12">
  <Button label="Preview" />
</Tooltip>

#body replaced the whole bubble, surface included, so call sites hand-copied the bubble's own classes to get them back. #content renders inside the bubble, so that wrapper goes away:

vue
<!-- Before -->
<Tooltip>
  <template #body>
    <div
      class="rounded bg-surface-gray-10 px-2 py-1 text-xs text-ink-base shadow-xl"
    >
      <span>Hide password</span>
    </div>
  </template>
  <Button icon="eye" />
</Tooltip>

<!-- After -->
<Tooltip>
  <template #content>
    <span>Hide password</span>
  </template>
  <Button icon="eye" />
</Tooltip>

If the content really does bring its own surface — an image preview, say — keep it and add bare:

vue
<Tooltip bare>
  <template #content>
    <img :src="url" class="max-h-40 rounded-4 shadow-xl" />
  </template>
  <span class="truncate">{{ filename }}</span>
</Tooltip>

Inputs

Covers TextInput, Textarea, Password, Checkbox, Switch, Rating, Slider. All share the labeling contract (label / description / error / required).

BeforeAfter
<Input> (removed)TextInput / Textarea / Select / Checkbox, or FormControl
Rating :rating_from:max
Rating :readonly:disabled
Switch @change@update:modelValue
Switch.labelClassesdata-* styling hooks
Checkbox.paddingpadded
Password :value prop (removed)v-model
TextInput / Textarea ref .elref .inputElement
size="xl" on any inputsize="lg"
FormLabel size (removed)fixed 13px label

The five rows below <Input> are removed, not aliased. The old names are silently ignored: a Rating with :rating_from="10" renders 5 stars, a :readonly Rating becomes interactive, a Switch @change handler never fires, and labelClasses / Checkbox.padding stop styling anything. Nothing breaks at build time, so grep for these names when upgrading.

<Input> is different: the import fails, so importing call sites break loudly. Apps that register components globally get no import error — the tag just fails to resolve and renders nothing, with a dev-only "Failed to resolve component" warning. Grep the tag, not the import: grep -rn '<Input\b' src.

Slider no longer hardcodes aria-label="Volume". Pass label explicitly so the control is announced correctly.

CircularProgressBar is deleted — the import fails. Use Progress for a linear bar, or render the arc yourself; there is no circular variant in v1.

Input sizes

The input size scale is now xs | sm | md | lg. xs is new, and xl is removed.

SizeSingle-line height
xs24px
sm28px (default)
md32px
lg40px

sm, md and lg render exactly as before. xl was never a bigger box: it drew lg's 40px height with an 18px font, so it was a font override wearing a size name.

vue
<!-- Before -->
<TextInput size="xl" />

<!-- After: pick the height you wanted -->
<TextInput size="lg" />

This applies to every component on the shared input scale: TextInput, Textarea, Password, Rating, Select, Combobox, MultiSelect, ItemListRow, FormControl, the DatePicker family, TimePicker, Duration, and the experimental CodeEditor and MultiEmailInput.

Progress, Slider, Switch, Checkbox, Avatar, Badge, Button and Dialog keep their own scales and are unaffected — <Dialog size="xl"> and <Avatar size="xl"> still work.

A leftover size="xl" no longer drops the geometry. Every input size lookup now falls back to that component's default (sm for the input family) and warns once in dev:

[frappe-ui] TextInput.size="xl" is not a supported value — falling back to
"sm". Supported: xs, sm, md, lg.

TypeScript flags the value at the call site. JavaScript call sites and bound values (:size="config.size") only surface through that warning, so check the dev console after upgrading.

FormControl.size widens the other way: it accepted only sm | md and now takes the whole scale. type="checkbox" renders on the narrower toggle scale, so size="lg" there is clamped to md rather than falling off the end.

Form typography — 13px labels, descriptions and Textarea text

Labels, descriptions and Textarea text are a fixed 13px, and labels and descriptions are ink-gray-6. Nothing to change in your source — this is a rendering change.

MemberBeforeAfter
Label (InputLabel)14px, ink-gray-513px, ink-gray-6
Label (FormLabel)12px sm / 14px md13px, ink-gray-6
Description13px, ink-gray-513px, ink-gray-6
Description (disabled)ink-gray-3ink-gray-4
Textarea text, every size14 / 16 / 18 / 20px13px

The two label implementations used to disagree — InputLabel, which FormControl renders through, was a flat 14px while FormLabel was 12px or 14px depending on size. Both are 13px now, so a FormLabel and a TextInput label finally match.

Textarea size still exists and still matters: it moves padding, corner radius and the minimum height. It no longer moves the type. The single-line input heights do not prescribe a Textarea height, so a lg Textarea is a roomier box of the same 13px prose.

The Textarea value colour is unchanged.

FormLabelsize prop removed

With the label size fixed at 13px, FormLabel's size axis was degenerate, so the prop is gone rather than kept as a no-op.

vue
<!-- Before -->
<FormLabel label="Email" size="md" />

<!-- After -->
<FormLabel label="Email" />

size now falls through as a plain HTML attribute, so nothing throws — the label just renders at 13px. TypeScript call sites get a build error.

If you passed size="md" you were getting 14px; you now get 13px. If you relied on the sm default you were getting 12px; you now get 13px. Either way the label lands on the accepted size, so the usual answer is to delete the attribute and keep the new value. To hold a different size, style the label yourself rather than reaching for a prop that no longer exists.

Password — value prop removed

value was a deprecated alternate way to set the password, seeding v-model on mount. It's gone. :value now falls through as a plain HTML attribute on the native <input> instead of seeding the model — the field still renders, so nothing throws or warns.

vue
<!-- Before -->
<Password v-model="password" :value="initialValue" />

<!-- After -->
<Password v-model="password" />
<script setup>
password.value = initialValue
</script>

TextInput, Textarea, Password — ref surface

TextInput and Textarea handed back { el }, a raw ref on the native element. It's now { focus, inputElement }: call focus(options?) to move keyboard focus, and read inputElement for the native element itself (a computed, so it can't be reassigned). Password gains the same pair — it previously exposed nothing.

This fails late rather than at build time: ref.value.el is undefined, so the next access — ref.value.el.focus() — throws at runtime, far from the upgrade. A typed ref catches it as a build error instead.

vue
<!-- Before -->
<TextInput ref="input" />
<script setup>
function focusIt() {
  input.value.el.focus({ preventScroll: true })
}
</script>

<!-- After -->
<TextInput ref="input" />
<script setup>
function focusIt() {
  input.value.focus({ preventScroll: true })
}
</script>

Duration already exposed focus(); it now takes the same options? parameter as the rest of the family.

focus() is on every input

Checkbox, Switch, Slider, RadioGroup, Rating and FormControl expose focus() too. They exposed nothing before. The shape is declared once as the exported InputExposed, so a generic form can type a ref to a control it did not choose:

vue
<script setup lang="ts">
import { ref } from 'vue'
import type { InputExposed } from 'frappe-ui'

const fields = ref<InputExposed[]>([])
function focusField(index: number) {
  fields.value[index]?.focus()
}
</script>

FormControl forwards focus() to whichever control its type resolved to.

Focus lands on the element Tab reaches, not on the container: Slider focuses the thumb, RadioGroup the selected option (the first enabled one when nothing is selected), and Rating the selected star (the first star when the value is empty), except in half-star mode where the whole control is one slider.

Attributes go to the control, class and style to the wrapper

An input has a layout wrapper and an interactive element. class and style go to the wrapper; name, aria-*, data-* and listeners go once to the interactive element.

This is a silent break in four components:

ComponentBefore
Checkboxevery attribute applied twice — wrapper and <input>
Switcheverything went to the wrapper, nothing to the control
RadioGroupeverything went to the wrapper, nothing to the radio group
Ratingeverything went to the wrapper, nothing to the control

Two things to audit. A listener now fires once, from the control:

vue
<!-- fired twice before; fires once now, and not from the padded row -->
<Checkbox padded label="Agree" @click="onClick" />

And a CSS rule written against the wrapper no longer matches a data-* you passed in. Move the selector to the control, or keep using class.

TextInput, Textarea, Password, Select, Combobox, MultiSelect, Slider, FormControl, Duration and the date pickers already followed the rule and are unchanged.

Rating defaults to size="sm"

Every other input defaults to sm. A <Rating> with no size now renders smaller. Pass size="md" to keep the old size.

vue
<!-- Before: rendered md -->
<Rating v-model="score" />

<!-- After: same pixels -->
<Rating v-model="score" size="md" />

Silent, and there is no codemod: an omitted prop is not a token a codemod can find, and rewriting every <Rating> in an app to pin the old default would be worse than the change. Three v1 app sites were already passing a smaller size by hand.

Duration forwards #label and #description

They used to be dropped. If you passed either slot to Duration, it renders now — check that it does not duplicate a label or description prop you also set.

FormControl no longer forwards variant to a checkbox

A checkbox draws no container surface, so it has no variant. The forwarded value used to land on the <input> as a stray variant="subtle" attribute. The type routes are unchanged: date renders DatePicker, time renders TimePicker, and a native date field is <TextInput type="date" />.

data-slot="label" on FormLabel

FormLabel carries the hook InputLabel already rendered, so one selector reaches every label in the library. Additive.

Input types the root publishes

Additive, except for two removals:

  • InputExposed — the template-ref shape every input implements.
  • PickerExposedInputExposed plus open and close, for the four pickers.
  • SelectionOption, SelectionGroup — the shared option shapes.
  • Dayjs — the date pickers hand one to formatter, disabledDate and the setter slot props, so the type has to be nameable.
  • DateRangeValue — both sides of DateRangePicker's v-model.
  • InputLabelingProps — for a wrapper that forwards label / description / error / required.

DatePicker's barrel lists its public types instead of re-exporting the whole module. DatePickerViewMode and DatePickerDateObj were calendar internals the wildcard published; they leave the root and the import fails. Nothing replaces them — they described the calendar's internal state.

SelectEmits, ComboboxEmits and MultiSelectEmits no longer redeclare the model events defineModel already declares, and RadioGroupEmits says RadioValue | undefined, which is what an unbound group starts at. A handler typed against the old shapes still compiles. Type a model handler off the model instead: (value: SelectOptionValue | null | undefined) => void. The extra undefined is there because the model prop is optional; neither Select nor Combobox ever emits it.

FileUploader

FileUploader reached structural bar in 1.0.0: TypeScript, flat props, and a security fix to the default it shares with useFileUpload / FileUploadHandler.

Uploads default to private

useFileUpload() and FileUploadHandler now resolve an upload with no stated private / is_private to private, not public. Coming from v0, FileUploader flips with them: it had no private prop at all and inherited the public default. It has uploaded private by default since v1.0.0-beta.21, so only pre-beta.21 upgrades see the component change.

ts
// Same call, before and after — the result changes:
await useFileUpload().upload(file, {})
await new FileUploadHandler().upload(file, {})
// Before: is_private=0 (public).  After: is_private=1 (private).

// State the intent explicitly instead of relying on the default:
await useFileUpload().upload(file, { private: false }) // public
await useFileUpload().upload(file, { private: true }) // private

If your app serves an uploaded file with no session — an avatar in an email digest, an image embedded on a public page — audit every call that omits private / is_private before upgrading. A file that flips to private returns 403 to a session-less request instead of the image.

Uploads reject an UploadError

upload, useFileUpload and FileUploadHandler reject with an exported UploadError instead of a plain Error, and state.error is typed with it. Branch on error.kind ('file-size' | 'network' | 'server' | 'abort') rather than matching the message text. A server failure also carries status, messages (the parsed server messages) and the raw response.

js
import { UploadError, upload } from 'frappe-ui'

try {
  await upload(file, { doctype: 'ToDo', docname: 'TODO-0001' })
} catch (error) {
  if (error instanceof UploadError && error.kind === 'file-size') {
    showError('That file is too large.')
  } else {
    throw error
  }
}

Existing catch blocks keep working: UploadError is an Error and its message is unchanged. FileUploadHandler no longer leaves the promise open when the request aborts; it rejects like the other two paths.

An aborted upload rejects an UploadError, not a DOMException

Silent break. upload() and useFileUpload() take an options.signal. When that signal fired, they rejected with new DOMException('Upload cancelled', 'AbortError'). They now reject an UploadError with kind: 'abort'. The message text is the same, but error.name is 'UploadError' instead of 'AbortError', and error instanceof DOMException is false. A catch block that tells a cancel from a failure by either of those stops matching, so a cancelled upload is reported to the user as an error.

js
// Before
try {
  await upload(file, { signal: controller.signal })
} catch (error) {
  if (error.name === 'AbortError') return // user cancelled
  showError(error.message)
}

// After
import { UploadError } from 'frappe-ui'

try {
  await upload(file, { signal: controller.signal })
} catch (error) {
  if (error instanceof UploadError && error.kind === 'abort') return
  showError(error.message)
}

The is_private upload option is removed

useFileUpload and FileUploadHandler took private and is_private for the same decision, with private winning. Only private is left, and it is a boolean. This is loud in TypeScript and silent in JavaScript, where an is_private you still pass is ignored and the upload falls back to the private default — so a public upload written as { is_private: 0 } becomes private.

js
// Before
await upload(file, { is_private: 0 })

// After
await upload(file, { private: false })

The is_private field on the uploaded file record the server returns is unchanged.

uploadArgs → flat props

The single uploadArgs object prop is gone. Its commonly-used fields are now flat props on the component:

Before (uploadArgs)After
private / is_privateprivate
folderfolder
doctypedoctype
docnamedocname
fieldnamefieldname
upload_endpointuploadEndpoint
optimizeoptimize
vue
<!-- Before -->
<FileUploader :uploadArgs="{ private: false, folder: 'Attachments' }" />

<!-- After -->
<FileUploader :private="false" folder="Attachments" />

This is silent: uploadArgs isn't a recognized prop anymore, so Vue passes it through as an inert HTML attribute on the root element. Nothing throws — the options it carried just stop applying, and (combined with the default flip above) a uploadArgs="{ private: false }" override that used to make an upload public silently starts uploading private instead. grep every <FileUploader> for uploadArgs= / :upload-args= and move each field to its flat prop.

file_url, method, type, params, max_width / max_height, and upload cancellation (signal) have no flat-prop equivalent — they had no measured use on the component. Use useFileUpload() directly for those.

Template ref — inputRef removed

FileUploader hands back nothing through a template ref, per ADR-0012. inputRef() (a function, despite the name) is gone with nothing in its place — the openFileSelector slot prop already covers what it opened.

vue
<!-- Before -->
<FileUploader ref="uploader" />
<script setup>
uploader.value.inputRef().click()
</script>

<!-- After -->
<FileUploader v-slot="{ openFileSelector }">
  <Button @click="openFileSelector">Upload</Button>
</FileUploader>

Default slot's error prop — always a string

The default slot's error prop is string | null, no longer unknown. Upload failures were always normalized to a message string; validation failures (a validateFile prop returning an Error) were not, so error could previously be an Error object too. Both paths normalize to a message string now.

vue
<!-- Before: had to guard against error being a string or an Error -->
<template #default="{ error }">
  {{ typeof error === 'string' ? error : error?.message }}
</template>

<!-- After: error is always a string -->
<template #default="{ error }">
  {{ error }}
</template>

This is silent: a slot that only ever did error.message (assuming the Error shape) now renders undefined instead of the validation message.

failure also fires for validation now. A validateFile that returns a message or throws emits failure with that value; v0 only wrote it to the slot's error. An existing @failure handler starts seeing validation rejections alongside upload errors.

fileToBase64 and the size-limit helpers — no longer exported

fileToBase64 is no longer exported from frappe-ui; the import fails at build time. There were no external call sites at the v1 sweep. Computing a file's base64 representation yourself is a few lines of FileReader.readAsDataURL. The size-limit helpers (formatBytes, getMaxFileSize, fileSizeLimitMessage) were only ever exported during the 1.0.0 betas and are internal now.

Divider

BeforeAfter
action.handleraction.onClick
positionalign

This is a silent break: handler is dropped as an unknown key, so the action button still renders and does nothing on click.

List family

Run npx list-v1 . from each app that imports components from frappe-ui/list. It updates selectors anchored to [data-slot="list-row"], Tailwind state variants on imported ListRow components, and the two statically named slots below:

BeforeAfter
[data-slot='list-row'][data-active][data-slot='list-row'][data-state='active']
[data-slot="list-row"][data-state="selected"][data-slot="list-row"][data-selected]
ListGroup #header#label
ListHeaderCellSort #suffix#sort-indicator
diff
-<ListGroup><template #header>Open</template></ListGroup>
+<ListGroup><template #label>Open</template></ListGroup>
 <ListHeaderCellSort align="end">
-  <template #suffix="{ direction }">…</template>
+  <template #sort-indicator="{ direction }">…</template>
 </ListHeaderCellSort>

Every ListRowBase now has data-state="active" or "inactive". Selection and interactivity are independent boolean attributes: data-selected and data-interactive. ItemListRow keeps its existing runtime contract. The sort-indicator slot keeps its edge-aware placement, including the leading edge for align="end".

The codemod leaves same-named local or globally registered components alone. If one still contains an old slot name, it reports the ambiguity for a manual check. It also exits non-zero and leaves a file unchanged when it finds a dynamic slot under either renamed component. These slot renames are silent breaks because Vue drops content passed under an unknown name.

After the codemod, grep for group-data-[active], unanchored data-state="selected" / data-active selectors, render-function slot keys named header or suffix, and dynamic slots. Those forms need a manual check because the tool cannot prove which component owns them.

ListRows.virtual is now a boolean. Move the object's overscan field to the new top-level overscan prop and set height on the parent List with rowHeight. Stop importing ListVirtualOptions or useVirtualRows; virtualization is component-owned. The ListRows slot also receives independent selected and active booleans.

ListView — moved to frappe-ui/experimental

ListView is not core v1 surface. It moves out of the root export to frappe-ui/experimental (P14 — no stability promise) and stays there until frappe-ui/list reaches full functional parity. The import fails at the root; switch the subpath:

ts
// Before
import { ListView, ListRow, ListHeader } from 'frappe-ui'

// After
import { ListView, ListRow, ListHeader } from 'frappe-ui/experimental'

Every other name in the family moves the same way: List, ListEmptyState, ListFooter, ListGroupHeader, ListGroupRows, ListGroups, ListHeaderItem, ListRowItem, ListRows, ListSelectBanner. Nothing about the component itself changed — only where it's imported from.

Calendar — moved to frappe-ui/experimental

Calendar is not core v1 surface. It moves out of the root export to frappe-ui/experimental (P14 — no stability promise) and parks there, API unchanged, until a redesigned calendar family replaces it. The import fails at the root; switch the subpath:

ts
// Before
import { Calendar, CalendarColorMap } from 'frappe-ui'

// After
import { Calendar, CalendarColorMap } from 'frappe-ui/experimental'

Every other name in the family moves the same way: CalendarActiveEvent and the types CalendarActions, CalendarCellClickData, CalendarConfig, CalendarEvent, CalendarMode, CalendarPublicProps, CalendarTimeFormat, GroupedCalendarEvents. Nothing about the component itself changed — only where it's imported from.

Charts (v1) — moved to frappe-ui/experimental

The first chart family is not core v1 surface. AxisChart, DonutChart, ECharts, FunnelChart, NumberChart and useAxisChartOptions move out of the root export to frappe-ui/experimental (P14 — no stability promise) and park there, API unchanged, while apps migrate. The import fails at the root; switch the subpath:

ts
// Before
import { AxisChart, DonutChart, NumberChart } from 'frappe-ui'

// After
import { AxisChart, DonutChart, NumberChart } from 'frappe-ui/experimental'

Nothing about the components changed — only where they're imported from. Apps that spread content from frappe-ui/tailwind keep their styles automatically.

For new code, use frappe-ui/charts instead. It is the replacement family and draws everything the old one did. Its props are flat and name the columns of your rows, so a config object becomes props:

vue
<!-- Before -->
<AxisChart
  :config="{
    data: rows,
    xAxis: { key: 'week' },
    series: [{ name: 'balance', type: 'area' }],
  }"
/>

<!-- After -->
<AreaChart :data="rows" x="week" y="balance" />

The port is not a pure rename. Four config keys have no same-named prop, so a rename-only port drops them silently — no build error, no type error, just a chart that scales or draws differently:

v0 config keyfrappe-ui/charts prop
yAxis.yMin / yAxis.yMaxyAxis.min / yAxis.max
y2Axis.yMin / y2Axis.yMaxy2Axis.min / y2Axis.max
swapXYhorizontal (BarChart only — a horizontal bar has no y2 axis)
colorspalette, or seriesConfig[key].color

Combo charts survive the port: seriesConfig[key].type takes 'bar' | 'line' | 'area', and seriesConfig[key].axis: 'y2' moves one series to the second value axis, configured with the chart-level y2Axis prop.

Sprite icons — moved to frappe-ui/experimental

The sprite-based Icon, IconPicker, and spritePlugin are not core v1 surface. They move from frappe-ui/icons to frappe-ui/experimental (P14 — no stability promise). The old import fails; switch the subpath:

ts
// Before
import { Icon, IconPicker, spritePlugin } from 'frappe-ui/icons'

// After
import { Icon, IconPicker, spritePlugin } from 'frappe-ui/experimental'

Nothing about the components changed — only where they're imported from. Apps that spread content from frappe-ui/tailwind keep IconPicker styles automatically — no Tailwind change needed. Note that root frappe-ui exports a different Icon; alias one if you import both, e.g. import { Icon as SpriteIcon } from 'frappe-ui/experimental'. The named SFC icons (CircleCheckIcon, HelpIcon, ...) stay on frappe-ui/icons. For new code, use lucide-* classes — they are the canonical way to render icons.

Alert

Alert is stateless now — it has no v-model and never hides itself. The parent renders it with v-if and reacts to @dismiss. Layout is content-driven: a description or a second action switches it to the banner layout; there is no variant prop. See the Alert component page for the full API.

BeforeAfter
unnamed v-model (visibility)v-if + @dismiss — the parent owns hiding
theme="yellow"theme="amber"
theme default 'blue'default 'gray'
variant="subtle" / "outline"nothing — one container look, layout is content-driven
type="warning"nothing — theme colors the status icon and the row action
default slot (body text)description prop, or the #description slot
dismissible default truedefault false — pass dismissible to keep the ×
#icon slot#prefix slot
#footer slotprimaryAction / secondaryAction props, or #actions slot
hand-rolled iconthe theme shows a status icon on its own; :icon="false" opts out

Every row is a silent break: Vue drops the unknown prop or slot with no error. The old v-model is the one to check first — a dismissed alert now stays on screen until the parent hides it:

vue
<!-- Before -->
<Alert v-model="showAlert" title="Payment failed" theme="yellow">
  <template #footer>
    <Button label="Retry" @click="retry" />
  </template>
</Alert>

<!-- After -->
<Alert
  v-if="showAlert"
  title="Payment failed"
  theme="amber"
  dismissible
  :primary-action="{ label: 'Retry', onClick: retry }"
  @dismiss="showAlert = false"
/>

An action is ButtonProps plus an onClick that receives { dismiss } — call context.dismiss() to emit the alert's dismiss event:

ts
const primaryAction = {
  label: 'Retry',
  onClick: ({ dismiss }) => {
    retry()
    dismiss()
  },
}

If the alert was really a promotional card in a sidebar, use the new SidebarCard component instead.

Badge

theme="orange" is removed. It was a deprecated alias that resolved to amber, so the replacement renders the same badge it always did.

BeforeAfter
theme="orange"theme="amber"

How the break shows up depends on whether the call site is typed:

  • TypeScript: loud. vue-tsc rejects the value, because the theme prop union no longer accepts the string. You get a compile error, not a surprise in production.
  • JavaScript and bound values: silent. The badge renders in the default gray theme and logs a one-time dev-mode warning naming the component, the prop and the value. Production logs nothing.
[frappe-ui] Badge.theme="orange" is not a supported value — falling back to
"gray". Supported: gray, blue, green, amber, red, violet.

A missed site is therefore a grey badge, not a broken page. Badge used to index a class map by theme and then index the result again by variant, so an unknown theme threw TypeError: Cannot read properties of undefined mid-render and took the parent render with it. All three of theme, variant and size now fall back to their defaults instead.

Do not rely on the fallback. It is a safety net for the upgrade, not a supported way to pass a colour — a grey badge where a coloured one belongs is still a bug, and the dev warning is the only thing that will tell you.

vue
<!-- Before -->
<Badge theme="orange" label="In Progress" />

<!-- After -->
<Badge theme="amber" label="In Progress" />

Check bound themes too, not only literal attributes. A status-to-theme map or a computed that returns 'orange' degrades the same way, and neither vue-tsc nor a grep for theme="orange" finds it:

ts
// Before
const themeByStatus = { open: 'orange', closed: 'green' }

// After
const themeByStatus = { open: 'amber', closed: 'green' }

Watch for orange as a fallback, which affects more sites than it looks — every caller that omits a theme lands on it:

vue
<!-- Before — every caller without `badge.theme` renders grey -->
<Badge :theme="badge.theme ?? 'orange'" />

<!-- After -->
<Badge :theme="badge.theme ?? 'amber'" />

If your app keeps its own colour vocabulary and cannot rename orange at the source, translate at the boundary instead of passing it through:

ts
const badgeTheme = tone === 'orange' ? 'amber' : tone

Sidebar is a bare frame — compose SidebarHeader / SidebarSection / SidebarLabel / SidebarItem in its default slot instead of passing config-object props. See the Sidebar component page for the full API.

BeforeAfter
:header="{ title, subtitle, menuItems }"<SidebarHeader :title :subtitle :menu-items /> as a child
:sections="[{ label, items }]"<SidebarLabel> + <SidebarItem> (or <SidebarSection>) as children
<template #header-logo><SidebarHeader>'s #prefix slot
<template #footer-items>plain markup in the default slot
<SidebarSection :items="rows"><SidebarSection> with <SidebarItem> children
<template #sidebar-item="{ item }">write the <SidebarItem> directly, no slot needed
item.conditionv-if on the composed <SidebarItem>
SidebarItem.isActiveSidebarItem.active
SidebarItem.toSidebarItem.route
SidebarHeader's #logo slot#prefix slot
Sidebar.disableCollapseSidebar.collapsible with the boolean inverted
SidebarRailItem variant="tile"variant="subtle"

Every removal here is a silent break. A dropped prop (header, sections, items, isActive) becomes a fall-through attribute on the component's root element, and content passed to the removed #sidebar-item slot is discarded — no build error, no type error, no warning. The sidebar renders as an empty frame. Grep for :header=, :sections=, :items=, #sidebar-item and isActive on these five components after upgrading.

vue
<!-- Before -->
<Sidebar
  :header="{ title: 'Frappe CRM', subtitle: 'crm.frappe.io', menuItems }"
  :sections="[
    {
      label: '',
      items: [{ label: 'Leads', to: '/leads', icon: 'lucide-user-plus' }],
    },
    { label: 'Views', collapsible: true, items: viewItems },
  ]"
/>

<!-- After -->
<Sidebar>
  <SidebarHeader title="Frappe CRM" subtitle="crm.frappe.io" :menu-items="menuItems" />
  <div class="flex-1 overflow-y-auto px-2">
    <SidebarItem label="Leads" route="/leads" icon="lucide-user-plus" />
    <SidebarSection label="Views" collapsible>
      <SidebarItem v-for="item in viewItems" :key="item.label" v-bind="item" />
    </SidebarSection>
  </div>
</Sidebar>

Sidebar no longer wraps the middle list in a scroll container or applies any padding — that's app-owned now (see the component page's Collapse section for the full composition contract).

SettingsDialog

Open state moves from the unnamed v-model to v-model:open, the name every other overlay in the library uses.

This is a silent break: Vue accepts the unknown modelValue prop with no error, so the dialog just never opens.

BeforeAfter
v-model="showSettings"v-model:open="showSettings"
@update:modelValue="onToggle"@update:open="onToggle"
vue
<!-- Before -->
<SettingsDialog v-model="showSettings" v-model:tab="tab"></SettingsDialog>

<!-- After -->
<SettingsDialog v-model:open="showSettings" v-model:tab="tab"></SettingsDialog>

v-model:tab is unchanged. Replace shortcut with keyboardShortcut; it defaults to "Mod+Shift+,", and false disables the registration. Unlike Dialog, SettingsDialog has no legacy unnamed-v-model binding to keep — open is the only visibility channel.

navigation-v1 removes bare and statically true shortcut props because the new default preserves their behavior. It converts static false values and reports dynamic boolean expressions for a manual combo-or-false decision.

Tabs

The monolithic Tabs is replaced by a composed family: Tabs, TabList, TabTrigger, TabPanel. The model is the trigger value, never an index. See the Tabs component page for the full API.

Run npx navigation-v1 . for the current TabButtons and Tabs active slot/state vocabulary. The broader v0 Tabs composition rewrite remains a hand edit. tokens-v2 rewrites Tailwind token names only — it never touches a component, prop, or slot name, so grep for the remaining old names rather than waiting for the build to tell you.

| Before | After | | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------- | | v-model="tabIndex" (index) | v-model="tab" (trigger value) | | :tabs="[{ label, icon }]" (required) | <TabTrigger> children; the tabs shorthand stays for generated sets | | label implied the value | value is required on every trigger | | as="div" | removed — compose and style the container directly | | <template #tab-item="{ tab, selected }"> | TabTrigger props (icon, iconLeft, route) and slots (#prefix, default, #suffix), or #tab-label in shorthand mode | | #prefix / #label / #suffix / #panel alongside :tabs | #tab-prefix / #tab-label / #tab-suffix / #tab-panel — every shorthand slot carries the tab- prefix; composed TabTrigger keeps plain #prefix / #suffix. An unknown slot name renders nothing, nothing throws | | extra fields on a tabs item ({ value, content }) | data: { content }, read as tab.data.content — extra keys are now a type error | | <template #tab-panel="{ tab }"> | <TabPanel :value> children; the shorthand slot is #tab-panel, back on its v0 name (it was briefly #panel in the betas) | | Tab.route string + hand-rolled route sync | route: RouteDestination on the trigger; selection derives from the route | | stale-index clamps for conditional tabs | built in: a stale model falls back to the first visible trigger and emits | | [&_[role='tablist']]:px-4 class blobs | <TabList class="px-4"> — the app owns the element | | built-in flex and overflow defaults | none — see Scrolling below; the tabs stop scrolling and overflow instead | | iconRight on a trigger or a tabs item | <template #suffix> on a composed TabTrigger, <template #tab-suffix> in shorthand mode — the icon silently stops rendering, nothing throws | | slot prop selected / checked | active | | data-state="checked|unchecked" | data-state="active|inactive" |

vue
<!-- Before -->
<Tabs v-model="tabIndex" :tabs="[{ label: 'Emails' }, { label: 'Calls' }]">
  <template #tab-item="{ tab, selected }">
    <span :class="selected ? 'text-ink-gray-9' : ''">{{ tab.label }}</span>
  </template>
  <template #tab-panel="{ tab }">
    <div>{{ tab.label }} content</div>
  </template>
</Tabs>

<!-- After -->
<Tabs v-model="tab">
  <TabList>
    <TabTrigger value="emails" label="Emails" />
    <TabTrigger value="calls" label="Calls" />
  </TabList>
  <TabPanel value="emails">Emails content</TabPanel>
  <TabPanel value="calls">Calls content</TabPanel>
</Tabs>

Tabs exposes nothing on the template ref, and TabList gains full variant parity with TabButtons: underline, subtle, ghost, browser-tab.

Scrolling

v0 shipped layout defaults: the root was flex flex-1 overflow-hidden flex-col, the tablist overflow-x-auto, and every panel flex flex-col overflow-auto. v1 sets none of them, because they broke as often as they helped — a Tabs that force-grows to fill its parent is wrong everywhere the tabs are not the whole screen.

Nothing throws. Inside a height-constrained container the panel stops scrolling and overflows instead. Check any call site that relied on it.

In composed mode the app owns the elements, so put the classes back where you want them:

vue
<Tabs v-model="tab" class="flex min-h-0 flex-1 flex-col">
  <TabList class="overflow-x-auto">…</TabList>
  <TabPanel value="emails" class="min-h-0 flex-1 overflow-auto">…</TabPanel>
</Tabs>

In shorthand mode the generated elements are not yours to class, so reach them through their data-slot hooks:

vue
<Tabs
  v-model="tab"
  :tabs="items"
  class="min-h-0 flex-1 [&_[data-slot=tab-list]]:overflow-x-auto [&_[data-slot=tab-panel]]:min-h-0 [&_[data-slot=tab-panel]]:flex-1 [&_[data-slot=tab-panel]]:overflow-auto"
/>

TabButtons

TabButtons keeps its radiogroup role — a value input, not a panel switcher — and aligns its vocabulary with the Tabs family. See the TabButtons component page for the full API.

BeforeAfter
type="ghost"variant="ghost"
direction="right"side="right" — the same prop name on TabList
TabButtonsType / TabButtonsDirection typesTabsVariant / TabsSide, shared with the Tabs family
:buttons="items" (deprecated):options="items"
{ label: 'Day' } (label as value)value is required on every option
{ active: true } fallbackthe v-model is the single source of truth
boolean value / modelValuestring | number only
wrapper divs / raw CSS for equal-width tabsfluid prop
iconRight on an option<template #suffix> — silent, nothing throws
hideLabel: true on an optionicon alone — the option is icon-only and label becomes its accessible name
theme / variant / size / loading on an optionremoved — options no longer forward Button props. Use Button directly for per-tab theming or a spinner
tooltip on an optionapp-owned help UI
numeric or missing labelrequired string label

Every prop rename here is a silent break: an unknown prop lands in $attrs and is spread onto the radiogroup root, so a TabButtons still on :buttons renders an empty track with no build error, type error, or warning. The destination codemod handles statically named component props only. Grep for :buttons, type=, direction=, hideLabel, tooltip, and non-string labels in option data.

Data fetching (useDoctype / useList)

The write methods on useDoctype (insert, delete, setValue, runDocMethod, runMethod) and on useList (insert, setValue, delete) used to share one request between all their submits. Each one now sends its own request, so the shared-request members are gone.

Nothing fails to build, so grep for these by hand. There are two failure modes, and one of them is quiet. Calling a removed method throws (delete.execute(), insert.reset()), and so does dotting into one (delete.params.name). A removed data member reads as undefined instead: runMethod.isFetching is always falsy, so a spinner silently never shows and nothing says why.

BeforeAfter
delete.loading && delete.params.name === row.namedelete.isLoading(row.name)
setValue.params.namesetValue.isLoading(name)
delete.execute() / .fetch() / .reload()delete.submit({ name })
insert.reset() / .abort()removed, no replacement
runMethod.isFetching / .isFinishedrunMethod.loading
setValue.promiseawait setValue.submit(...)
delete.urlremoved, no replacement

All eight now have the same five members: submit(), data, error, loading and isLoading().

isLoading() takes whatever identifies one submit:

js
todos.delete.isLoading(row.name)
todos.setValue.isLoading(row.name)
todos.runDocMethod.isLoading(row.name, 'archive')
todos.runMethod.isLoading('sync_all')
todos.insert.isLoading() // no argument: a new row has no name yet

insert.isLoading() gives the same answer as insert.loading. It is there so every write method reads the same way.

More changes you will not see at build time:

  • submit() now resolves with its own response. Code that fired two submits and read the result of the first was receiving the second one's data, or null. If you queued submits to work around that, you can drop the queue.

  • data and error belong to the submit that started last, not the one that answered last. A slow submit that comes back after a newer one writes nothing and clears nothing. It still answers its own caller with its own outcome — resolving with its response, or rejecting with its error.

  • data is no longer reset to null when a submit fails. It used to be, because the shared request cleared it on any not-ok response. It now keeps the last successful response.

    js
    await todos.setValue.submit({ name: 'TODO-1', status: 'Done' })
    await todos.setValue.submit({ name: 'TODO-2', status: 'Done' }) // fails
    
    todos.setValue.data // still the TODO-1 response
    todos.setValue.error // the failure

    Test error, not data, to tell a failed submit from a successful one. if (!todos.setValue.data) used to mean "the last save failed" and no longer does.

  • error is no longer cleared when a submit starts. It used to be, which erased the error of a sibling submit still in flight. It now stands until the newest submit settles. To blank an error banner while a retry runs, hide it on loading yourself.

  • submit() rejects on any failure. It resolves with the response, or rejects with the error. A failed validate already rejected; a failed request used to resolve with null. Both reject now.

    js
    // Before
    const doc = await todos.insert.submit({ title: 'Buy milk' })
    if (!doc) return showError(todos.insert.error)
    
    // After
    try {
      const doc = await todos.insert.submit({ title: 'Buy milk' })
    } catch (e) {
      showError(e)
    }

    null no longer means "it failed". A server that answers with null resolves with null, like any other response. Every if (!result) check after a submit() has to become a try / catch or a .catch(), and an unawaited submit() now needs a .catch() or it becomes an unhandled rejection.

  • useList's insert and delete now send to the baseUrl you passed to useList. They used to ignore it and hit the current origin. setValue already honoured it, so all three write methods now agree. useDoctype was never affected.

  • A stale setValue or delete on the same row no longer writes the shared document and list stores, and no longer triggers useList's auto-refetch. Every request carries a dispatch number, and a store rejects a write that a later-dispatched request already made. Submits with different keys, and keyless submits such as inserts, stay independent and all of their hooks still fire. Nothing to change — this is here so you can drop the workarounds.

useDoc writes and useNewDoc

useDoc's setValue, delete and every methods: entry, and all of useNewDoc, held one shared request too. Each submit now sends its own. They keep the full useCall surface — same members, same types — so there is nothing to rename.

One silent behavior change: a second submit no longer cancels the first. Both requests reach the server. If you relied on the abort to drop a superseded save, debounce or guard the call site yourself. data and error follow the same newest-wins rule as useDoctype above, and loading stays true until every submit settles.

Data fetching: writes reject, reads resolve

One rule now covers every composable: a write rejects when it fails, a read resolves. A failed write must not let its caller run the success path. A failed read leaves the last value on screen and reports through error.

CallBeforeAfter
useCall submit()resolved with nullrejects
useDoc setValue.submit(), delete.submit()resolved with nullrejects
a useDoc methods: member's submit()resolved with nullrejects
execute() / fetch() / reload() in every composableresolvedresolves (no change)

useDoctype and useList write methods already rejected — see the section above. This change brings the rest in line with them.

Silent. Nothing fails to build. The success path simply stops running, and an unawaited submit() becomes an unhandled rejection.

js
// Before: the failure fell through to the success path
const doc = await todo.setValue.submit({ status: 'Closed' })
if (!doc) return showError(todo.setValue.error)
toast.success('Saved')

// After
try {
  await todo.setValue.submit({ status: 'Closed' })
  toast.success('Saved')
} catch (error) {
  showError(error)
}

Grep for .submit( and check each site. Three patterns need work:

  • if (!result) after a submit()null is a valid response now, not a failure. Move the handling into a catch.
  • a submit() that is not awaited — add .catch(...) or it reaches window.onunhandledrejection.
  • a submit() inside a Promise.all — one rejection now fails the whole batch. Use Promise.allSettled if that is not what you want.

error and onError are unchanged: both still fire, whether the call rejects or not. Reads need no change.

useDoc method names cannot shadow the object's own members

A methods: key that is already a member of what useDoc returns (doc, error, loading, reload, setValue, delete, and the rest) used to overwrite it silently. It throws at setup now and names the collision. Rename the key and keep the server method name:

js
// Before: `reload` silently replaced the document's own reload()
useDoc({ doctype: 'ToDo', name, methods: { reload: 'reload_items' } })

// After
useDoc({
  doctype: 'ToDo',
  name,
  methods: { reloadItems: { name: 'reload_items' } },
})

useNewDoc and useDoc methods take fewer options

An insert and a document method are writes that run when you call submit(). immediate and refetch are fixed to false for both, and useNewDoc no longer accepts refetch, cacheKey or staleOnError at all.

This is loud in TypeScript and silent in JavaScript, where the values are now ignored. refetch: true was the dangerous one: it re-sent the insert on every keystroke in the form bound to doc.

js
// Before: an insert per edit to the draft
const draft = useNewDoc('ToDo', { description: '' }, { refetch: true })

// After
const draft = useNewDoc('ToDo', { description: '' })
await draft.submit()

Data fetching (exports)

useFrappeFetch is no longer exported. It is the raw createFetch instance useCall, useDoc and useList are built on: it sets the Frappe headers and parses the response, and leaves the URL, the params and the caching to you. Pick the composable that matches what you are fetching.

BeforeAfter
useFrappeFetch('/api/v2/method/…')useCall({ url })
useFrappeFetch('/api/v2/document/…')useDoc({ doctype, name })
useFrappeFetch('/api/v2/document/…?…')useList({ doctype, … })

This is a build failure at the import, so nothing changes silently.

js
// Before
import { useFrappeFetch } from 'frappe-ui'
const { data } = useFrappeFetch('/api/v2/method/ping').get()

// After
import { useCall } from 'frappe-ui'
const ping = useCall({ url: '/api/v2/method/ping' })

FrappeResponseError is exported now. A Frappe error response raises it — it lands on .error, and the write methods above reject with it. The class was never exported, so you could not tell it apart from a network or parse failure. Narrow it with instanceof and you get title, type, indicator and exception:

ts
import { FrappeResponseError } from 'frappe-ui'

try {
  await todos.insert.submit({ title: 'Buy milk' })
} catch (e) {
  if (e instanceof FrappeResponseError) {
    showError(e.title, e.type)
  } else {
    throw e
  }
}

FrappeRequestError is FrappeResourceError

The error type the resource layer raises is renamed. Both errors in the library are server responses, so request versus response named nothing; the name says the layer that raises it now. Only apps on a v1 beta are affected: v0 never exported either name.

BeforeAfterRaised by
FrappeRequestErrorFrappeResourceErrorcall, frappeRequest, createResource and the other v1 resources
FrappeResponseErrorFrappeResponseErroruseCall, useDoc, useList, useDoctype, useNewDoc

FrappeResponseError does not change. The fields on both are the same as before, and there is no alias: importing the old name is a build failure.

ts
// Before
import type { FrappeRequestError } from 'frappe-ui'

// After
import type { FrappeResourceError } from 'frappe-ui'

The two narrow differently, which also does not change. FrappeResponseError is a class, so instanceof works. FrappeResourceError is a type over a plain new Error(...) with fields assigned, so there is no value to test: read the field you need.

ts
try {
  await call('frappe.client.get_list', { doctype: 'ToDo' })
} catch (error) {
  const e = error as FrappeResourceError
  if (e.exc_type === 'PermissionError') showPermissionMessage()
  else throw error
}

FrappeUIError is removed

The exported error type for an input's error prop is gone. The prop is typed where it is declared, and a wrapper that forwards it reads the type from InputLabelingProps, which the root now exports.

ts
// Before
import type { FrappeUIError } from 'frappe-ui'
defineProps<{ error?: string | FrappeUIError }>()

// After
import type { InputLabelingProps } from 'frappe-ui'
defineProps<{ error?: InputLabelingProps['error'] }>()

The prop also accepts a string[] now, which is what ErrorMessage.message takes. One array renders one line per entry, and an empty array means no error. Everything that worked before still works: a string, or an Error that may carry messages.

Upload exports

UploadError is exported. Every upload failure rejects with it, and useFileUpload's state.error holds it — see FileUploader.

isPrivateUpload and the UploadPrivacy type are removed. They existed for the is_private upload option, which is gone; pass private instead. This is a build failure at the import. No app used either name.

A destination prop is typed RouteDestination

Every prop that takes a router destination (Button.route, the Sidebar, Tabs, Breadcrumbs and Menu item types, and the rest) is typed RouteDestination instead of vue-router's RouteLocationRaw. The root exports RouteDestination and RouteLocationObject.

The accepted values do not change: a path string, or an object with name / params / path / query / hash. The owned type exists so the generated API docs print a readable name rather than vue-router's minified internal ones.

Resource and editor barrels name their exports

frappe-ui/resources and frappe-ui/editor used export * from their implementation files, which published every name those files happened to export. Both now list what they publish. The names apps import are all still there; an import of an internal helper that was never meant to be public fails at build time.

Tree

The Tree was rebuilt from a single recursive node renderer into a stateful forest. It takes a nodes array and scopes its per-row slots as #item-*. Expansion moved off the nodes and into a keyed v-model:expanded, so the tree never writes to the objects you pass in. The options blob is gone — sizing moves to CSS variables. Keyboard navigation, role="tree" ARIA, and opt-in drag-and-drop are new.

BeforeAfter
:node="root" (single root object):nodes="[root]" (array of roots)
node.collapsed / internal collapsev-model:expanded="keys" (keys of the open nodes)
:options="{ rowHeight, indentWidth }"--tree-row-height / --tree-indent CSS vars
:options="{ showIndentationGuides }"guides="connectors" | "lines" | "none"
:options="{ defaultCollapsed: true }"nothing — collapsed is the default
:options="{ defaultCollapsed: false }"treeRef.expandAll()
#node="{ node, isCollapsed, toggleCollapsed }"#item="{ node, expanded, toggle, … }"
#label#item-label
#iconbuilt-in chevron; override via #item

Nothing here fails loudly. :node and :options become fall-through attributes, and content passed to the old #node / #label / #icon slots is discarded — the default row renders in its place. A production build renders an empty tree. Three things warn, and only in dev: the now-required nodes prop, the removed per-node expanded field, and the removed boolean form of v-model:expanded (see below). Grep for :node=, :options=, #node, #label and #icon on Tree specifically.

vue
<!-- Before -->
<Tree :node="root" node-key="name" :options="{ defaultCollapsed: false }">
  <template #label="{ node }">{{ node.title }}</template>
</Tree>

<!-- After -->
<Tree :nodes="[root]" node-key="name" v-model:expanded="expanded">
  <template #item-label="{ node }">{{ node.title }}</template>
</Tree>

Expansion is keyed, and the tree stops writing to your nodes

v-model:expanded is an array of the keys of the open nodes. A key that is absent means collapsed, so a tree with no bound model renders its roots and nothing else. Drop any expanded field you set on node data — it is no longer read. A dev build warns once when it finds one, and once more if you still pass the old boolean to v-model:expanded; production is silent either way.

vue
<!-- Before — the tree wrote `expanded` back onto your objects -->
<Tree :nodes="nodes" node-key="name" />

<!-- After — expansion lives in your state, as keys -->
<script setup>
const expanded = ref(['src', 'src/components'])
</script>
<Tree :nodes="nodes" node-key="name" v-model:expanded="expanded" />

Every toggle assigns a new array rather than mutating one in place, so shallow watchers, immutable stores and undo logs see the change.

The old boolean expand/collapse-all switch is gone. Call expandAll() / collapseAll() on the component ref instead:

vue
<script setup>
const tree = ref(null)
const expanded = ref([])
</script>

<template>
  <Button @click="tree.expandAll()">Expand all</Button>
  <Tree ref="tree" :nodes="nodes" node-key="name" v-model:expanded="expanded" />
</template>

expand(key), collapse(key) and toggle(key) are exposed too. All five are programmatic, so disabled does not block them.

Drag-and-drop is opt-in: set draggable, gate drops with :move="({ node, target, position }) => …", and persist from @drag-end="(info) => …"info is { node, from, to, position, oldIndex, newIndex }, or null when the drag is cancelled.

Icons

The single recommended way to pass an icon anywhere in the library is a lucide-* string (rendered via the Tailwind mask plugin) or a Component escape hatch (P11). FeatherIcon is removed per ADR-0008 — it shipped @deprecated in code, so nothing marked deprecated ships in 1.0.0.

Breaking, loud: import { FeatherIcon } from 'frappe-ui' and <FeatherIcon> fail at the import. Replace a direct usage with the lucide-* class form:

vue
<!-- Before -->
<FeatherIcon name="plus" class="size-4" />
<!-- After -->
<span class="lucide-plus size-4" aria-hidden="true" />

Feather and lucide share most icon names, so <FeatherIcon name="x"><span class="lucide-x"> is usually a direct rename — check each name individually against lucide.dev since a few differ or were renamed.

Breaking, silent: every icon-name prop across the library (Button.icon / iconLeft / iconRight, Dialog.icon, Alert.icon, SidebarCard.icon, Dropdown/ContextMenu item icon, TabButtons options icon / iconLeft, TabTrigger.icon / iconLeft, the Icon component's icon prop) used to render a bare feather-style name (e.g. "edit", "chevron-down") via FeatherIcon. That fallback is gone: only a lucide-* string, an emoji or symbol glyph, or a Component renders. Any other string renders nothing. No build or type error — the icon silently disappears. A dev-mode console warning names the component, the prop, and the offending value once per (component, prop). Prefix the name with lucide-:

vue
<!-- Before -->
<Button icon="plus" />
<!-- After -->
<Button icon="lucide-plus" />

Same for icon-name strings in Dropdown options:

js
// Before
const options = [{ label: 'Edit', icon: 'edit' }]

// After
const options = [{ label: 'Edit', icon: 'lucide-pen' }]

Card, ListItem, standalone <Toast> (removed)

Three unmaintained wrappers are gone in v1, per ADR-0008 — each shipped @deprecated in code and had zero call sites left across our census of downstream apps.

Card wrapped a title/subtitle/actions layout with a manual loading state. There's no drop-in replacement; rebuild the layout with plain markup, using LoadingText or Skeleton for the loading state:

vue
<!-- Before -->
<Card title="Title" subtitle="Subtitle" :loading="loading">
  <template #actions><Button label="Edit" /></template>
  Content
</Card>

<!-- After -->
<div class="flex flex-col rounded-6 border px-6 py-5">
  <div class="flex items-baseline justify-between">
    <h2 class="text-lg font-semibold">Title</h2>
    <Button label="Edit" />
  </div>
  <p class="mt-1.5 text-ink-gray-6">Subtitle</p>
  <LoadingText v-if="loading" class="mt-4" />
  <div v-else class="mt-4">Content</div>
</div>

ListItem rendered a title/subtitle/actions row. Same story — no drop-in replacement, rebuild with plain markup:

vue
<!-- Before -->
<ListItem title="Title" subtitle="Subtitle">
  <template #actions><Button label="Edit" /></template>
</ListItem>

<!-- After -->
<div class="flex items-center justify-between py-3">
  <div>
    <h3 class="font-medium">Title</h3>
    <p class="text-ink-gray-6">Subtitle</p>
  </div>
  <Button label="Edit" />
</div>

Standalone <Toast>import { Toast } from 'frappe-ui' and <Toast> fail at the import. This only removes the raw ToastRoot-based component; the imperative API is unaffected and is what you almost certainly want:

vue
<!-- Before -->
<Toast v-model:open="open" message="Saved" type="success" />

<!-- After -->
<script setup>
import { toast } from 'frappe-ui'
toast.success('Saved')
</script>

<ToastProvider> (mount once near your app root) is unchanged. The current API is plain toast() plus toast.success() / toast.error() / toast.warning() / toast.info(). v0's toast.create(), toast.remove() and toast.removeAll() still work, but warn once in dev — move them to toast(...) and toast.dismiss(...).

Tokens

Run the v2 token codemod from the app you are migrating:

sh
npx --package frappe-ui@beta tokens-v2 --dry-run .

Review the output, then run it without --dry-run:

sh
npx --package frappe-ui@beta tokens-v2 .

The codemod renames espresso color tokens like bg-surface-white to bg-surface-base, merges static text size + weight class pairs (for example text-base font-medium to text-base-medium), and renames the removed radius aliases (rounded-mdrounded-5, see below). Run it once per codebase; the token migration is not idempotent because some v2 names overlap with v0 names. The radius renames are idempotent and also run on already-migrated codebases.

In every mode, the codemod stays inside the directories you give it. A symlink whose real path leaves the target — a file or a directory — is skipped and listed at the end of the run. Run the codemod on each real package root, so a shared package linked into several apps is migrated once.

After upgrading to [email protected], run the codemod again. Apps that already ran it will only get the typography correction (text-lgtext-md, text-xltext-lg, ...) and the radius renames. Apps that still have pre-v2 color tokens can pass --force, but review the output carefully because color tokens may double-shift.

Already ran the typography correction too? Pass --radius-only. It performs only the radius renames (safe to repeat) and reports removed tokens — it never touches color or text-size names, so nothing can double-shift.

Unused tokens and utilities removed

A pre-1.0.0 audit (#940) removed the token names below — all had zero call sites across frappe-ui and every consumer app. Each is a silent break: the class or --* variable just stops applying, with no build or type error.

text-tiny
text-13xl / text-14xl / text-15xl / text-16xl (and their -medium/-semibold/-bold/-black variants)
shadow-status
--elevation-status
surface-alert-button-default / -info / -success / -warning / -error
ink-alert-button-default / -info / -success / -warning / -error
surface-alpha-gray-2-overlay
surface-alpha-red-1 … surface-alpha-red-7
outline-alpha-red-2 / -3 / -4

The codemod reports only some of these: the dead text sizes, the text-*-black styles, and the two alpha-red families. It has no rule for shadow-status, --elevation-status, the alert-button tokens, or surface-alpha-gray-2-overlay — grep for those five by hand.

If your build used any of these, replace them with the nearest step on the regular scale — e.g. text-16xltext-12xl, shadow-statusshadow-sm, surface-alert-button-errorsurface-red-2 (or whichever variant+theme pairing the design calls for).

Radius aliases removed

The named radius aliases are removed in 1.0.0. Numbered tokens are the only radius vocabulary now (ADR-0006). rounded-none and rounded-full are kept.

This is a silent break. The preset replaces Tailwind's borderRadius scale, so an unmigrated rounded-md emits no CSS at all — no build error, no type error, just square corners. Run the codemod, then grep for leftover aliases.

BeforeAfterpx
roundedrounded-48
rounded-smrounded-14
rounded-mdrounded-510
rounded-lgrounded-612
rounded-xlrounded-716
rounded-2xlrounded-820

The same map applies to every directional and corner form (rounded-t-lgrounded-t-6, rounded-tl-smrounded-tl-1), to the logical sides (rounded-ss-mdrounded-ss-5, and the same for s, e, se, es, ee), to the bare directional aliases (rounded-trounded-t-4), and to variant prefixes (hover:rounded-2xlhover:rounded-8). Pixel values are identical — the migration changes vocabulary, not rendering.

The codemod handles all of these. One caveat: the bare word rounded is plain English, so the codemod only rewrites it inside quoted strings and @apply rules. A class list inside a multi-line template literal can be missed — grep for bare rounded after running it.

The alias CSS variables go away with the aliases. Hand-written CSS that reads var(--radius-sm) / var(--radius-md) / var(--radius-lg) / var(--radius-xl) / var(--radius-2xl) resolves to nothing — the same silent break. The codemod only rewrites rounded-* classes, so grep for --radius-(sm|md|lg|xl|2xl) and switch to the numbered variables (var(--radius-5) for the old --radius-md, same map as above).

text-*-black styles removed

The text-<size>-black / text-p-<size>-black style classes are removed — zero usage anywhere, and the Figma weights behind them were corrupt export data. This is also a silent break: the class stops emitting CSS.

The codemod no longer merges font-extrabold (or font-black) onto a text-*-black class. It flags the pair under "needs manual attention" instead. If you need weight 800, keep font-extrabold; there is no letter-spacing-corrected style class for it.

Ink chromatic scales shift one level

The updated espresso v2 tokens shift every chromatic ink scale down one level: the new ink-red-1 is the old ink-red-2, and so on for all 11 chromatic families. The scales now end at -9. ink-gray keeps its own 9-step scale and does not shift. This is a silent break: every ink-<family>-N site renders one shade off after the token update.

Run the codemod once with --ink-shift:

sh
npx --package frappe-ui@beta tokens-v2 --ink-shift .

Run the codemod in the same change as the frappe-ui upgrade that ships the shifted tokens. The upgrade without the codemod renders one shade off. The codemod without the upgrade also renders one shade off. Land both together.

Add --dry-run first to review the renames before they apply:

sh
npx --package frappe-ui@beta tokens-v2 --ink-shift --dry-run .

--ink-shift cannot be combined with --force or --radius-only; the run exits with an error rather than hiding which renames applied. A dry run is not a safety probe either: it only warns when it finds a run-once marker, while a real run refuses outright.

This mode runs only the ink shift — no color renames, no typography, no radius renames. Run it exactly once per codebase. There is no way to detect a prior run from file content (ink-red-5 is a valid name before and after), so a second run double-shifts. To guard against that, --ink-shift takes directory targets only, and a real run writes a .tokens-v2-ink-shift marker file in each target directory. It refuses to run again while a marker exists in the target, an ancestor, or anywhere in the target subtree. The marker is written before the first file rewrite, so an interrupted run refuses to retry instead of double-shifting; restore the tree with git, delete the marker, and re-run. Commit the marker with the migration — on a fresh clone without it the guard is gone, and a teammate's re-run double-shifts. Delete it only to re-run the shift on purpose.

Each marker is created exclusively, so two runs on the same directory cannot both start: the second stops before it rewrites anything. For nested targets (a repo root and one of its subdirectories) the run searches again after it claims its markers, and stops if another run claimed an overlapping tree. Both runs can stop this way. Neither has rewritten a file at that point, so re-run whichever tree is still unshifted.

The marker search follows the same symlink rule as the run: a marker in a linked external package never blocks a target the run would not rewrite. Run the codemod on each real package root directly, so every migrated tree gets its own marker. If the refusal names a marker inside a vendored dependency (for example vendor/frappe-ui/.tokens-v2-ink-shift), that dependency is already shifted — leave its marker alone and target the directories that do not contain it. Pointing at the app root will not help: the search walks the whole subtree, so any ancestor of the vendored copy finds the same marker and refuses again. Target src/ and your other own trees instead.

The old ink-<family>-1 step was white. The new -1 is a light tint, so these sites have no automatic destination. The codemod flags them under "needs manual attention". The usual fix is text-white (or the literal CSS color white in hand-written CSS).

Tight text styles are 1.35

This is a silent break. The text-* styles from text-2xs to text-4xl, with every weight variant, move from line-height 1.15 to 1.35. Each line of text is 0.2em taller: 2.4px at 12px, 2.8px at 14px, 4.8px at 24px. The text-p-* styles and text-5xl and up do not change.

leading-tighter is a new class that sets 1.15, the old default. leading-tight keeps Tailwind's 1.25.

frappe-ui components keep their height. Your own markup can move:

  • A row sized by its content grows. A menu, a list of search results or a stack of cards grows by 2.4 to 5px for each row.
  • A fixed-height box can clip or push its text off-centre. For example h-4, a min-h-* that the text used to fill, or an offset such as top-[41px] that you measured from the old line.
  • Code that reads the value breaks, for example a chart layout that hard-codes 1.15.

Add leading-tighter to single-line chrome in a fixed-height box:

vue
<!-- Before: the row is 28px, a 16.1px line and 12px of padding -->
<button class="flex w-full items-center px-2 py-1.5 text-base">
  {{ item.label }}
</button>

<!-- After: leading-tighter keeps the 16.1px line, so the row stays 28px -->
<button class="flex w-full items-center px-2 py-1.5 text-base leading-tighter">
  {{ item.label }}
</button>

For long prose, use text-p-*: its line height is 1.4 to 1.6.

Packaging and tokens

Two loud breaks and three silent ones. Run the codemod first:

sh
npx -p frappe-ui packaging-v1 --dry-run .
npx -p frappe-ui packaging-v1 .

Point it at the project root, not at src: it reads the Vite config and the source that uses icons in the same run. It rewrites the Tailwind preset path, adds lucideIcons: true where the app still needs the resolver, and reports any plugin call it cannot decide.

The preset path

The frappe-ui/src/utils/tailwind.config shim is deleted, and no path under frappe-ui/src/... resolves. A loud break: the build stops with Package subpath './src/utils/tailwind.config' is not defined by "exports". The exports map refused that path before this release; the file is now gone as well.

js
// Before
import preset from 'frappe-ui/src/utils/tailwind.config'
// After
import preset from 'frappe-ui/tailwind'

The same module also exports content, the globs that emit classes inside the package. Tailwind v3 ignores a preset's own content, so spread them into yours. They are already absolute paths, resolved from the installed package, so do not prefix them:

js
import preset, { content } from 'frappe-ui/tailwind'

export default {
  presets: [preset],
  content: [...content, './index.html', './src/**/*.{vue,js,ts}'],
}

lucideIcons is off

frappeui() no longer installs the ~icons resolver, unplugin-auto-import and unplugin-vue-components. A loud break for an ~icons import (Failed to resolve import "~icons/lucide/check") and a quiet one for an auto-imported tag: <LucideCheck /> renders as an unknown element and Vue warns in the console.

js
// Before
frappeui({ frappeProxy: true })
// After
frappeui({ lucideIcons: true, frappeProxy: true })

Two other ways out, if you would rather not carry the plugins: pass the icon name as a class (<span class="lucide-check size-4" />, drawn by the preset's own icon plugin), or import only the resolver from frappe-ui/vite/lucideIconsPlugin.

The focus ring is an outline

The --focus-<name> variables are removed. They held a box-shadow value; --focus-outline-<name> holds the outline form, which does not change the element's size. This is a silent break: box-shadow: var(--focus-red) resolves to nothing and the ring disappears.

css
/* Before */
.my-input:focus-visible {
  box-shadow: var(--focus-red);
}
/* After */
.my-input:focus-visible {
  outline: var(--focus-outline-red);
}

The names are default, red, green, amber, blue and violet. Each is 2px in light mode and 3px in dark. Grep for --focus- and check every hit carries the outline- segment.

rounded-9, w-wizard and min-w-50

Three silent value changes from the one-scale rewrite:

BeforeAfterWhat to do
rounded-9 = 999px100pxNothing, unless you used it as a circle. Then use rounded-full.
w-wizard = 650pxremovedw-[650px], or a width token of your own.
min-w-50 = 18rem12.5remmin-w-[18rem] to keep the old size.

Everything else on the scale keeps its value, and the scale is now complete: integers 1 to 128 and half steps 0.5 to 19.5. Tailwind 3.4 reads theme('spacing') for width, height, size, minWidth, maxWidth, minHeight and maxHeight, so p-*, m-*, gap-*, w-*, h-*, size-*, min-w-*, max-w-*, min-h-* and max-h-* all read the same numbers.

Dependencies

  • Install tailwindcss yourself: it is a peer now, >=3.4.2 <4. An install on Tailwind v4 fails.
  • vite and vitepress are optional peers, with shiki, @shikijs/transformers and @vue/compiler-dom, which frappe-ui/vitepress imports. Nothing changes unless you import frappe-ui/vite or frappe-ui/vitepress without having them.
  • If your app imported ora, slugify, prosemirror-tables, @tailwindcss/line-clamp or a @tiptap/extension-* package through frappe-ui, declare it yourself. They are no longer frappe-ui dependencies.
  • In tsconfig.json, keep types: ["vite/client"]. frappe-ui ships TypeScript source, so your compiler checks it, and it reads import.meta.env.

Editor

The v0 monolith <TextEditor> (imported from frappe-ui) is replaced by the frappe-ui/editor family: a headless <Editor> you compose with kits (bundled, configurable extension sets) and building-block menus. Everything moves to the frappe-ui/editor subpath; TextEditor and its siblings (TextEditorBubbleMenu, TextEditorFixedMenu, TextEditorFloatingMenu, TextEditorContent, createEditorButton) are removed from top-level frappe-ui in 1.0.0, and so are the v0 extension helpers ImageExtension and createSuggestionExtension with their types SetImageOptions, BaseSuggestionItem and CreateSuggestionExtensionOptions — nothing editor-related is exported from root. See the Editor page for the full API and recipes.

Not migrated yet? All eleven v0 names are parked, unchanged, in frappe-ui/experimental as an interim import path. It is unstable — no deprecation window — and will be removed once consumers migrate:

ts
import {
  TextEditor,
  TextEditorBubbleMenu,
  TextEditorFixedMenu,
  TextEditorFloatingMenu,
  TextEditorContent,
  createEditorButton,
  ImageExtension,
  createSuggestionExtension,
} from 'frappe-ui/experimental'
import type {
  SetImageOptions,
  BaseSuggestionItem,
  CreateSuggestionExtensionOptions,
} from 'frappe-ui/experimental'
ts
// Before
import { TextEditor, TextEditorFixedMenu } from 'frappe-ui'
// After
import {
  Editor,
  EditorFixedMenu,
  RichTextKit,
  articleToolbar,
} from 'frappe-ui/editor'
BeforeAfter
import … from 'frappe-ui'import … from 'frappe-ui/editor'
<TextEditor><Editor>
:content="x" @change="x = $event"v-model="x" (@change still emitted)
HTML string onlyv-model + format="json" for a JSON value
:starterkit-options="{ heading: { levels } }"RichTextKit.configure({ heading: { levels } }) in :extensions
auto-loaded extension set (no opt-out)explicit :extensions — pick CommentKit / RichTextKit / InlineKit
:mentions / :tags propskit.configure({ mention: { items, nodeView }, tag: { items } })
:bubble-menu="true"<EditorBubbleMenu :items="articleToolbar"> in the default slot
:floating-menu="true"<EditorFloatingMenu :items>
<TextEditorFixedMenu :buttons><EditorFixedMenu :items>
<TextEditorContent><EditorContent>
menu :buttonsmenu :items
hand-rolled textEditorMenuButtons arraycommentToolbar / articleToolbar / minimalToolbar presets
#top / #bottom / #editor slotsone default slot — you render EditorContent + menus yourself
:uploadFunction (optional, frappe default):upload-function (required to enable uploads)
UploadedFile from frappe-ui/editor (beta)UploadedMedia

Compose, don't configure

v0 took every option as a prop on <TextEditor> and auto-loaded every extension. v1 renders no chrome of its own — you place the building blocks inside its default slot and they pick up the editor from context (the :editor prop is only needed when composing primitives without <Editor>):

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Editor,
  EditorContent,
  EditorBubbleMenu,
  RichTextKit,
  articleToolbar,
} from 'frappe-ui/editor'

const content = ref('')
const extensions = [
  RichTextKit.configure({ heading: { levels: [2, 3, 4, 5, 6] } }),
]
</script>

<template>
  <Editor v-model="content" :extensions="extensions" placeholder="Write…">
    <EditorBubbleMenu :items="articleToolbar" />
    <EditorContent class="prose max-w-none" />
  </Editor>
</template>

Pick the kit per surface: CommentKit (light — no table/toc/slash), RichTextKit (full document), InlineKit (single-line). Configure kit members in place rather than via props — e.g. mentions/tags through kit.configure({ mention: {...}, tag: {...} }). To keep the mention/tag nodes rendering but disable the live popups, pass mention: { items: null }.

For a fully custom layout (e.g. a title <textarea> as a sibling of the body), skip <Editor> and drive useEditor yourself — see Composing primitives — rendering <EditorContent> and the menus as siblings of your own markup.

Editor suggestion and fixed-menu names

Suggestion components now name the role they fill. Replace component with nodeView in Mention.configure(...) or a kit's mention options. Replace it with listComponent in SuggestionExtension.configure(...). The mention and kit paths are silent runtime breaks for JavaScript consumers; TypeScript catches the removed keys. Development builds warn when they see either one.

ts
// Before
Mention.configure({ items: users, component: MentionNode })
SuggestionExtension.configure({ ...options, component: SuggestionList })

// After
Mention.configure({ items: users, nodeView: MentionNode })
SuggestionExtension.configure({ ...options, listComponent: SuggestionList })

EditorFixedMenu also renames buttonSize to size. Run the idempotent codemod before checking the two suggestion shapes manually:

sh
npx --package frappe-ui@beta editor-v1 --dry-run .
npx --package frappe-ui@beta editor-v1 .
vue
<!-- Before -->
<EditorFixedMenu button-size="sm" :items="items" />
<!-- After -->
<EditorFixedMenu size="sm" :items="items" />

Editor option types

Kit members, the upload handler and the two floating menus are typed. Run yarn type-check (or vue-tsc) after upgrading: every edit below is reported at the call site.

Make every constructed UploadedMedia include file_url. The type is exported, and so is the handler type:

ts
// Before
const uploadFunction = async (file: File) => {
  const doc = await upload(file)
  return doc // any shape
}
// After
import type { UploadFunction } from 'frappe-ui/editor'
const uploadFunction: UploadFunction = async (file, options) => {
  const doc = await upload(file, {
    doctype: 'Blog Post', // attach it, see below
    docname: post.name,
    onProgress: options?.onProgress,
  })
  return doc // must carry file_url; extra fields pass through
}

The second argument is optional. A one-argument handler still compiles.

Rename the editor's UploadedFile import to UploadedMedia. The root frappe-ui export UploadedFile is the File document that upload() resolves with. It keeps its name. TypeScript reports the old editor import:

ts
// Before
import type { UploadedFile } from 'frappe-ui/editor'
// After
import type { UploadedMedia } from 'frappe-ui/editor'

upload from frappe-ui now fits uploadFunction with no cast. Do not pass it bare. Bare upload stores a private file attached to nothing, and only the uploader can load it. Attach the file to the document that holds the content. The file stays private, and anyone who can read that document can read it. Or pass private: false when the content is public anyway:

ts
import { upload } from 'frappe-ui'
import type { UploadFunction } from 'frappe-ui/editor'

const uploadFunction: UploadFunction = (file, options) =>
  upload(file, {
    doctype: 'Blog Post', // or `private: false` for public content
    docname: post.name,
    signal: options?.signal,
    onProgress: options?.onProgress,
  })

Remove dead code, codeBlock and link keys from StarterKit options. The frappe extensions of those names are separate members, so the keys did nothing:

ts
// Before
StarterKit.configure({ link: false, codeBlock: false })
// After
StarterKit

Inside a kit, move heading out of starterKit:

ts
// Before
RichTextKit.configure({ starterKit: { heading: { levels: [2, 3] } } })
// After
RichTextKit.configure({ heading: { levels: [2, 3] } })

InlineKit.starterKit takes false per member only:

ts
// Before
InlineKit.configure({ starterKit: { code: { HTMLAttributes: {} } } })
// After
InlineKit.configure({ starterKit: { code: false } })

Add StyleClipboard and Toc explicitly when an editor needs them.RichTextKit no longer registers them by default:

ts
// Before
RichTextKit
// After (only if you call insertTableOfContentsNode or read
// editor.storage.styleClipboard)
RichTextKit.configure({ toc: {}, styleClipboard: {} })

imageViewer is unchanged and stays on.

A custom slash-command list now replaces the built-in menu. items was accepted and ignored before, so check what you pass:

ts
RichTextKit.configure({
  slashCommands: {}, // built-in menu
  // slashCommands: { items: myCommands },  // replaces the built-in list
  // slashCommands: false,                  // no slash menu
})

Check Bubble and Floating menu option objects against EditorMenuOptions. The supported keys are side, align, strategy, offset, flip, shift, hide, inline, scrollTarget and shouldShow.

placement becomes side plus align. The menus now position the way Popover, Select, Dropdown, HoverCard and the pickers do, with the same PopoverSide and PopoverAlign values. Split the hyphen:

vue
<!-- Before -->
<EditorBubbleMenu :options="{ placement: 'top-start' }" />
<!-- After -->
<EditorBubbleMenu :options="{ side: 'top', align: 'start' }" />

<!-- Before -->
<EditorFloatingMenu :options="{ placement: 'bottom' }" />
<!-- After -->
<EditorFloatingMenu :options="{ side: 'bottom' }" />

align defaults to center, which is the unaligned variant. Set neither axis and TipTap's own default still applies: top for the bubble menu and right for the floating menu. The exported EditorMenuPlacement type is removed; import PopoverSide and PopoverAlign from frappe-ui if you need to name the values.

The rest of this one removes working settings. The options prop used to be TipTap's own Floating UI bag, so the keys below type-checked and reached Floating UI. They are now a compile error:

RemovedWhat to do
placementside plus align, as above.
arrowDrop it. The menus render no arrow element.
sizeSize the menu with CSS on your own toolbar markup.
autoPlacementSet side, and leave flip on for the fallback.
onShow, onHide, onUpdate, onDestroyWatch your own state, or use shouldShow for the show or hide branch.
offset: { mainAxis, crossAxis }offset: <number>, the main-axis gap.
flip, shift, hide, inline in object formtrue to keep the middleware on its defaults, or drop the key.
vue
<!-- Before -->
<EditorBubbleMenu
  :options="{
    placement: 'top',
    offset: { mainAxis: 8 },
    flip: { fallbackPlacements: ['bottom'] },
    onShow: () => (menuOpen = true),
  }"
/>
<!-- After -->
<EditorBubbleMenu :options="{ side: 'top', offset: 8, flip: true }" />

flip: true keeps Floating UI's default fallback placements, which is the opposite side. If a menu genuinely needs middleware configuration, mount TipTap's BubbleMenu directly and render EditorFixedMenu inside it.

Update mention and tag data to include label and value; use the original slot item for extra fields. label is the text, value is the stored id:

ts
// Before
const mentions = users.map((u) => ({
  id: u.name,
  label: u.full_name,
  email: u.email,
}))
// After
const mentions = users.map((u) => ({
  value: u.name,
  label: u.full_name,
  email: u.email,
}))

The list no longer rewrites your objects, so the item slot receives the one you supplied, email and all. getMentions() returns { label, value } — read value where you read id.

Tags are the same, with value optional so a newly typed tag has none yet.

Gotchas

  • Tailwind must scan frappe-ui's editor source. Menu icons are literal lucide-* class strings living in frappe-ui/src/molecules/**. Add that glob to your tailwind.config.js content, or the toolbar / bubble / floating icons silently won't be generated.
  • Uploads need an explicit handler. v0 silently invoked the Frappe upload; v1 requires :upload-function. In a Frappe app: (file) => useFileUpload().upload(file, {}).
  • TipTap must be v3. The v1 editor is built on TipTap 3 — pin @tiptap/core, @tiptap/pm, and @tiptap/vue-3 to ^3.

Family stylesheets (list-style.css / editor-style.css)

The manual frappe-ui/list-style.css and frappe-ui/editor-style.css exports are removed. The frappe-ui/list and frappe-ui/editor barrels are now marked as side-effectful, so each family's CSS lands in your production build automatically when you import anything from its subpath.

Delete the manual imports; there is nothing to add back:

css
/* Before */
@import 'frappe-ui/list-style.css';
@import 'frappe-ui/editor-style.css';
/* After: nothing — the CSS ships with the subpath import */

The build fails loudly (Missing "./list-style.css" specifier in "frappe-ui" package) until the lines are gone.

Code editor

frappe-ui/code-editor still resolves in v1, and that is the trap: same subpath, different API. The v0 CodeEditor and CodePreview are gone. A family replaces them, built the way the editor family is: an engine, a renderless component, one part, and a kit.

CodePreview breaks loudly, because the name no longer exists. CodeEditor does not. <CodeEditor v-model="script" label="Script" language="json" /> still imports and still mounts, and then draws nothing: the new one is renderless, and every capability comes from extensions. Port every call site rather than waiting for a build error.

ts
// Before
import { CodeEditor, CodePreview, loadLanguage } from 'frappe-ui/code-editor'

// After
import {
  CodeEditor,
  CodeEditorContent,
  CodeKit,
  loadLanguage,
} from 'frappe-ui/code-editor'

frappe-ui ships no labeled field. The old component drew a label, a description, an error and a required marker. The new CodeEditor renders nothing at all: it owns the view and the v-model, and your app draws the chrome around it. Write one thin field component and reuse it at every call site. The Desk/FormLayout field lives in @framework/ui.

v0v1
language="json":extensions="[CodeKit, json()]"
variant / size propsCSS var sets on your wrapper
placeholder="SELECT 1"CodeKit.configure({ placeholder: 'SELECT 1' })
disabled:editable="false"
label / description / error / requireddrawn by your field
--cm-max-height--code-max-height
automatic JSON lint[lintGutter(), linter(jsonParseLinter())] in extensions
@overflow on the field@overflow on <CodeEditorContent>
CodePreviewcopy it into your app; it is a markdown renderer, not an editor

The types CodeLanguage, CodeEditorProps, CodeEditorEmits and CodePreviewProps are gone. LanguageKey, CodeEditorOptions, CodeEditorExposed and CodeKitOptions are the new ones.

A minimal port:

vue
<script setup>
import { CodeEditor, CodeEditorContent, CodeKit } from 'frappe-ui/code-editor'
import { json } from '@codemirror/lang-json'

const value = ref('{}')
</script>

<template>
  <CodeEditor v-model="value" :extensions="[CodeKit, json()]">
    <CodeEditorContent class="min-h-40" />
  </CodeEditor>
</template>

Install the language packages you use. The ten @codemirror/lang-* packages and @codemirror/lint are optional peer dependencies now, so an app downloads only what it renders. loadLanguage('sql') throws an error naming the package when it is missing.

Full API: the code editor docs.

hljs-theme.css and tailwind/tokens.js (removed)

Both subpaths are gone. Both breaks are loud: the specifier stops resolving.

RemovedReplacement
frappe-ui/hljs-theme.cssnone — frappe-ui/editor ships its own code-block highlighting
frappe-ui/tailwind/tokens.jsfrappe-ui/tailwind/tokens — new specifier and new names

The subpath is back, minus the .js. That is not the whole change. Two things move: the specifier, and every name behind it except fontSize. Dropping the extension alone leaves you importing names the module does not export, and the import fails to link. Rewrite the specifier and the import list together.

An earlier revision of this guide sent you to the preset. That was wrong. The preset is a Tailwind Config, and its colours, radii and sizes are built inside plugin.js when Tailwind calls the plugin, so you cannot read a value out of it.

js
// Before
import {
  borderRadius,
  boxShadow,
  fontSize,
  generateCSSVariables,
  generateSemanticColors,
} from 'frappe-ui/tailwind/tokens.js'

// After
import {
  radius,
  shadows,
  fontSize,
  cssVariables,
  semanticColors,
} from 'frappe-ui/tailwind/tokens'

The names moved, and so did the shapes:

BeforeAfterWhat changed
borderRadiusradiusRenamed. The numbered scale in px, 0 to 9 plus none and full.
boxShadowshadowsRenamed. Still a flat map of composed box-shadow strings, with the same six elevation values plus none. Two things move: DEFAULT now sits right after base instead of last, and the status key is gone. It came from elevation.custom, which the Figma export stopped filling.
generateCSSVariables()[':root']cssVariables.lightA constant, not a function, and keyed by theme rather than by selector. generateCSSVariables()['[data-theme="dark"]'] is cssVariables.dark, which still holds the same dark layer: the re-valued semantic and focus properties, plus the dark ramps under --dark-* names.
generateSemanticColors()semanticColors.light or semanticColors.darkA constant, not a function, and one level deeper: the old return was theme-agnostic color-mix(...) strings, so it had no theme key. Pick a side, and get resolved oklch(...) values.
fontSizefontSizeThe one name that survives. Each entry is now an object { fontSize, lineHeight, letterSpacing, fontWeight }, not a [size, meta] tuple, so a call site that destructured const [size, meta] = fontSize.base breaks.

The old module re-exported colorPalette.js, so its colours carried Tailwind sentinels. A consumer got oklch(L C H / <alpha-value>) from generateColorPalette(), and a color-mix(...) wrapper around calc(<alpha-value> * 100%) from generateSemanticColors(). A colour picker handed either one renders an empty swatch. The new exports carry no sentinel.

colors, focusRing, fontFamily, fontWeight, screens, spacing and tracking are exported from the same subpath. See Tailwind Setup.

frappe-ui/frappe and frappe-ui/drive (removed)

Both subpaths are gone in v1. frappe-ui is a UI library; the members that know about doctypes, onboarding flows, or billing moved to @framework/ui (the ui/ package in the frappe repo). Every break here is loud — the import path stops resolving.

Before (frappe-ui/frappe)After
useTelemetry, telemetryPlugin@framework/ui
useOnboarding, GettingStartedBanner, IntermediateStepModal, HelpModal, showHelpModal, minimize@framework/ui
TrialBanner, SignupBanner@framework/ui
DataImport@framework/ui
Link, LinkProps, LinkEmits, LinkExposed, LinkOption@framework/ui (superset, see below)
Filter@framework/ui (superset, see below)
OnboardingSteps, HelpCenter, showHelpCenterremoved — they live on inside @framework/ui's HelpModal
frappe-ui/drive, frappe-ui/drive/*removed, no replacement

@framework/ui peer-depends on frappe-ui, so add it as a dependency if your app does not carry it yet, then change the import path:

js
// Before
import { useTelemetry, TrialBanner } from 'frappe-ui/frappe'

// After
import { useTelemetry, TrialBanner } from '@framework/ui'

Two replacements are supersets of what they replace — existing call sites work unchanged:

  • Link adds redirectable / editable props and redirect / edit emits.
  • Filter adds a useFilters composable, parseFilters / serializeFilters, and an operator registry.

The drive components were removed because the drive app already owns the live copy of all six; nothing imported the subpath.

Finally, drop the stale Tailwind glob. The frappe/ directory no longer ships, so this line in tailwind.config.js scans nothing:

js
// Delete this line
'./node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}',

Better: replace the hand-copied list with the content export, which tracks the library's source directories for you.

Autocomplete (removed)

Autocomplete is gone in v1. It merged single- and multi-select via the multiple boolean; v1 splits them: Combobox for single, MultiSelect for multiple.

The import fails, so your build tells you where every call site is. Three things inside those call sites change quietly instead, and each has a before/after below: the v-model payload, the group key, and the open slot prop, which was a function and is now a boolean.

Sweep your codebase:

bash
grep -rln '<Autocomplete\b' src --include='*.vue'   # find usages
grep -rln ':multiple' src --include='*.vue'         # these become MultiSelect
grep -rn 'items:' src --include='*.vue'             # grouped options — see below
Before (Autocomplete)After
:multiple="false" (default)use Combobox
:multiple="true"use MultiSelect
v-model (option or value)v-model (value / value array)
@change@update:modelValue (@update:selectedOption for the option)
grouped { group, items }grouped { group, options }
placement (string)side + align
:showFooter#footer slot (MultiSelect has built-in)
:bodyClassesdata-slot CSS
:maxOptionsno equivalent
#target="{ togglePopover }"#trigger, with no click handler (open is now a boolean)
#prefix / #suffixsame (#suffix now replaces chevron)
#item-prefix / #item-suffix slot props { active, selected, option }{ item, query, selected }option is renamed and active is gone, so a carried-over option.label throws during render

The v-model payload inverts

Autocomplete took and emitted the whole option object; both replacements model the value onlyCombobox is string | number | null, MultiSelect is (string | number)[]. Code that reads country.value off the model gets undefined rather than a type error, since the model was loosely typed.

vue
<!-- Before -->
<Autocomplete v-model="country" :options="countries" @change="onChange" />
<!-- country === { label: 'India', value: 'in' } -->

<!-- After -->
<Combobox
  v-model="country"
  :options="countries"
  @update:model-value="onChange"
/>
<!-- country === 'in' -->

Still need the whole option — for its description, an id field, anything beyond the value? Listen to @update:selectedOption, which carries it:

vue
<Combobox
  v-model="country"
  :options="countries"
  @update:selected-option="(option) => (label = option?.label ?? '')"
/>

Grouped options: itemsoptions

The key holding a group's children is now options, matching the top-level prop. Combobox and MultiSelect throw and name the group if they find the old key, so this one is caught the first time the picker opens — but only then, not at build time. Dropdown and ContextMenu share the rename and fail the other way: the group is dropped in silence (see Dropdown and ContextMenu).

vue
<!-- Before -->
<Autocomplete :options="[{ group: 'Asia', items: [india, japan] }]" />

<!-- After -->
<Combobox :options="[{ group: 'Asia', options: [india, japan] }]" />

#target#trigger, and drop the click handler

The slot is renamed, and the wiring inside it changes. Autocomplete handed #target a togglePopover function you had to call yourself. Combobox and MultiSelect attach the open toggle to the #trigger element for you, so the handler is not just unnecessary — a togglePopover() carried through the rename throws togglePopover is not a function on every click. The popover still opens, because the component's own handler already ran, so this reads as "works, but noisy" until someone looks at the console.

vue
<!-- Before -->
<Autocomplete :options="fields">
  <template #target="{ togglePopover }">
    <Button label="Add filter" @click="togglePopover()" />
  </template>
</Autocomplete>

<!-- After -->
<Combobox :options="fields">
  <template #trigger>
    <Button label="Add filter" />
  </template>
</Combobox>

open changed from a function to a boolean, and that part is silent. On #target it was the function that opened the popover, so anything reading it as a value — v-if="open", :class="{ 'rotate-180': open }" — was reading a function object and was always truthy. On #trigger it is the real open state, so those expressions start doing what they always looked like they did.

Combobox's #trigger receives { open, disabled, query, selectedOption, displayValue, clear, setOpen, close }. MultiSelect's receives { open, disabled, query, selectedOptions, clear, setOpen, close } — plural, and with no displayValue. Use setOpen for a trigger that has to open the popover from somewhere other than its own click.

The trigger shape changed — pass trigger="button" to keep v0's

Autocomplete rendered a button showing the selection, with the search box inside the popover. Combobox defaults to trigger="input" — the trigger is the search field. Pass trigger="button" to keep the old shape.

FormControl type="autocomplete" (removed)

This one is silent. FormControl is a dispatcher: with the autocomplete case gone, the type falls through to TextInput and is still forwarded as an html input type. The result is <input type="autocomplete">, which every browser renders as a plain text box — a picker that turned into a text field. No runtime error, and no build error in plain JS; TypeScript callers do get one, because 'autocomplete' is no longer in the type union. A dev-only console.error names it.

vue
<!-- Before -->
<FormControl type="autocomplete" :options="countries" v-model="country" />
<!-- country === { label: 'India', value: 'in' } -->

<!-- After -->
<FormControl type="combobox" :options="countries" v-model="country" />
<!-- country === 'in' -->

The v-model payload inverts here too, for the same reason as above. Or use the standalone Combobox, which exposes the full set of props and slots without the wrapper.

HTTP transport and the FrappeUI plugin

v1 has one HTTP path. frappeRequest is it; call is a thin wrapper over it for the common "POST to a whitelisted method" case. request, createCall and initSocket are gone, and app.use(FrappeUI) is down to a single option.

BeforeAfter
import { request }import { frappeRequest }
createCall(options)wrap call yourself, or use frappeRequest
import { initSocket }your own io(...) connection
app.use(FrappeUI, { config })setConfig(key, value) per entry
app.use(FrappeUI, { call })import { call } where you need it
this.$resources (implicit)app.use(FrappeUI, { resources: true })

The first three are build failures — your bundler or type-check names them. The last three fail at runtime instead: a dropped config is applied nowhere, and reading this.$call or this.$resources throws with the fix in the message.

frappeRequest is not a drop-in for request. Check three things at each call site you swap: the method defaults to POST, not GET; a URL that is not a path is prefixed with /api/method/; and it resolves with the response body's message, not the whole body. The shape you get back changes with no error.

call now honours setConfig

call built its own fetch and never read the config, so requestBaseUrl and requestHeaders were quietly ignored on every call() while frappeRequest respected them. That inconsistency is fixed, which means a call in an app that sets either one now behaves differently — usually the way you assumed it already did. Two knock-on effects worth checking:

  • If you set requestBaseUrl for local dev against a remote site, call now goes to the remote site too, with credentials: 'include'.
  • If you set serverMessagesHandler, _server_messages returned by a call now reach it. Previously only frappeRequest and the resources fed it, so expect toasts from paths that used to be silent.

call's signature, the value it resolves to, and the { response, status, error } object passed to onError are all unchanged.

The plugin's config option is gone

js
// Before
app.use(FrappeUI, {
  config: {
    resourceFetcher: frappeRequest,
    defaultListUrl: 'gameplan.extends.client.get_list',
    systemTimezone: window.system_timezone || null,
  },
})

// After
setConfig('resourceFetcher', frappeRequest)
setConfig('defaultListUrl', 'gameplan.extends.client.get_list')
setConfig('systemTimezone', window.system_timezone || null)
app.use(FrappeUI)

Passing a removed option is not a type error if your main.js is plain JS, so the plugin logs a dev-mode warning naming the option it ignored. Note that setConfig only accepts keys of FrappeUIConfig — if you were passing a key that isn't one, it was never read and can be deleted.

$resources is opt-in

The plugin used to install the v1 resources Options API mixin by default, so this.$resources, $getResource, $getDocumentResource, $getDoc, $getListResource and $refetchResource existed in any app that called app.use(FrappeUI). It now installs only when asked:

js
app.use(FrappeUI, { resources: true })

resources is a boolean. It used to be typed as an object of resource definitions, and the plugin never read what was in it — only whether it was set. Passing an object is a type error now, and still installs the mixin at runtime, so nothing breaks while you fix it:

js
// Before
app.use(FrappeUI, { resources: { todos: { url: '' } } })

// After
app.use(FrappeUI, { resources: true })

npx -p frappe-ui data-v1 ./src makes that edit. It rewrites only an object literal written inline at the app.use(FrappeUI, …) call, and reports any site it will not touch (a spread, a computed key, a variable) instead of guessing.

Nothing changes for Composition API code — createResource, createListResource and createDocumentResource never went through the plugin. You need the option only if you declare a resources: { … } block in a component's options.

You will not have to work this out from a blank screen. A component that declares resources without the option throws on creation, naming itself and the fix. Vue routes that throw through its own error handling, which rethrows in dev and only logs in production, so the read is guarded too: reading this.$resources throws at the access in every build, straight into your own code. resourcesPlugin is still exported if you would rather install it directly.

initSocket is gone

It was a nine-line io() wrapper, and the plugin created one by default — which meant apps that also built their own socket held two live connections per page load. If you relied on the $socket global the plugin set, create the connection yourself:

js
import { io } from 'socket.io-client'

const host = window.location.hostname
const port = window.location.port ? ':9000' : ''
const protocol = port ? 'http' : 'https'
const siteName = import.meta.env.DEV ? host : window.site_name

app.config.globalProperties.$socket = io(
  `${protocol}://${host}${port}/${siteName}`,
  { withCredentials: true },
)

Until you do, reading this.$socket throws with that instruction rather than returning undefined and crashing in whatever realtime handler reads it next. Assigning your own replaces the guard. The same applies to $call, the other global the plugin used to install — import call from frappe-ui instead.

Composables and directives renamed

Three groups of root exports changed names. All of them fail at the import, so the build lists every call site — but one of them has a quiet second half, and that is the one to read.

useThemeuseColorScheme

theme means color tone everywhere else in the library (theme="blue" on a Button), so the light/dark composable gives the word back.

BeforeAfter
useTheme()useColorScheme()
Theme typeColorScheme type
currentThemecolorScheme — read-only
setTheme(t)setColorScheme(t)
toggleTheme()toggleColorScheme()
getSystemTheme()useColorScheme().resolvedColorScheme — a ref
initializeTheme()removed — useColorScheme() initializes itself

The read-only colorScheme is the quiet part. The ref was only a third of the state; the data-theme attribute and the theme key in localStorage are the other two. Assigning to the old writable ref moved the ref and left the document and the stored value behind, so the app desynced with no error.

js
// Before — moved the ref, desynced the page
const { currentTheme } = useTheme()
currentTheme.value = 'dark'

// After
const { setColorScheme } = useColorScheme()
setColorScheme('dark')

The data-theme attribute and the theme localStorage key keep their names, so app CSS targeting [data-theme='dark'] and saved user preferences still work.

Scroll container: nine members become two

useScrollContainer published nine members for the two things apps do: read the shell's scroll element, and know whether it has been scrolled.

BeforeAfter
activeScrollContainershellScrollContainer
getScrollContainer()shellScrollContainer.value
useScrollContainer().isScrolleduseShellScrolled({ threshold })
scrollTo(options) / scrollToTop()call them on shellScrollContainer.value
registerScrollContainer / unregisterScrollContainerremoved — internal to the two shells
UseScrollContainer / UseScrollContainerOptionsremoved

useShellScrolled() returns the boolean ref directly, not an object:

js
// Before
const { isScrolled } = useScrollContainer({ threshold: 12 })

// After
const isScrolled = useShellScrolled({ threshold: 12 })

The shell prefix is load-bearing: both resolve only while a DesktopShell or a MobileShell is mounted. Without one, useShellScrolled stays false and warns once in development.

Directives: vFocus and vOnOutsideClick

<script setup> auto-registers a directive only when the imported binding is spelled vFoo, so the old names had to be aliased at every call site.

BeforeAfter
focusDirectivevFocus
onOutsideClickDirectivevOnOutsideClick
visibilityDirectiveremoved, no replacement
vue
<!-- Before -->
<script setup>
import { onOutsideClickDirective as vOnOutsideClick } from 'frappe-ui'
</script>

<!-- After -->
<script setup>
import { vOnOutsideClick } from 'frappe-ui'
</script>

useCall: a throwing beforeSubmit now cancels the submit

Previously a beforeSubmit hook that threw was caught and logged, and the request was sent anyway. Now the throw propagates: the request is not sent and submit() rejects with the hook's error.

This is a silent behavior change. If one of your beforeSubmit hooks can throw, the submit it used to let through now stops. Either handle the rejection at the call site or make the hook non-throwing to keep the old behavior. A hook that returns normally is unaffected — it still cannot stop the request.

beforeSubmit may now be async (() => void | Promise<void>); it was always awaited, only the type said otherwise.

error is untouched by a cancelled submit. The throw reaches you only through the rejected submit(), so error still holds the last request's error — an app that renders failures from error alone renders nothing when a hook cancels. This covers every place beforeSubmit is accepted: useCall, useNewDoc, and each entry in useDoc's methods:.

pageMetaPlugin (removed)

pageMetaPlugin and the global mixin it installed are gone. A pageMeta() component option still compiles — it's a plain, unread object key — but nothing calls it anymore, so document.title and the favicon stop updating. This is a silent break: no error, no warning, the page just stops retitling itself.

BeforeAfter
app.use(pageMetaPlugin)delete — nothing to install
pageMeta() { return { title, emoji } }usePageMeta(() => ({ title, emoji })) in setup()
vue
<!-- Before -->
<script>
export default {
  pageMeta() {
    return { title: this.pageTitle, emoji: '🌈' }
  },
}
</script>

<!-- After -->
<script setup>
import { usePageMeta } from 'frappe-ui'

usePageMeta(() => ({ title: pageTitle.value, emoji: '🌈' }))
</script>

usePageMeta works the same everywhere — see the composables page.

CommandPalette

CommandPalette and CommandPaletteItem leave the root export. The family is rebuilt as seven composable parts in frappe-ui/experimental, where it stays until gameplan, helpdesk and this site all run on it (P14 — no stability promise).

The root import fails to resolve, so your build names every call site.

ts
// Before
import { CommandPalette, CommandPaletteItem } from 'frappe-ui'

// After
import {
  CommandPalette,
  CommandPaletteInput,
  CommandPaletteList,
  CommandPaletteGroup,
  CommandPaletteItem,
  CommandPaletteEmpty,
  CommandPaletteFooter,
} from 'frappe-ui/experimental'

The groups prop becomes markup

groups is gone. Write the rows as parts, so a group renders whatever it needs without a per-group component escape hatch.

vue
<!-- Before -->
<CommandPalette
  v-model:open="open"
  v-model:query="q"
  :groups="groups"
  @select="onSelect"
/>

<!-- After -->
<CommandPalette v-model:open="open" v-model:query="q" @select="onSelect">
  <CommandPaletteInput placeholder="Search" />

  <CommandPaletteList>
    <CommandPaletteGroup
      v-for="group in groups"
      :key="group.title"
      :label="group.hideTitle ? undefined : group.title"
    >
      <CommandPaletteItem
        v-for="item in group.items"
        :key="item.name"
        :value="item"
        :disabled="item.disabled"
      >
        <template v-if="item.icon" #prefix>
          <span :class="[item.icon, 'mr-3 size-4']" />
        </template>
        {{ item.title }}
        <template v-if="item.description" #suffix>{{ item.description }}</template>
      </CommandPaletteItem>
    </CommandPaletteGroup>
  </CommandPaletteList>

  <CommandPaletteEmpty />
</CommandPalette>

CommandPaletteList is the list itself and the only part that scrolls. It may own rows and groups and nothing else, so the field, the empty state and the footer stay outside it.

BeforeAfter
:groups="groups"CommandPaletteList + CommandPaletteGroup + CommandPaletteItem
group.title:label on CommandPaletteGroup
group.hideTitleleave label out
group.componentwrite the row in the item's slots
item.icon#prefix on CommandPaletteItem
item.description#suffix on CommandPaletteItem
item.disabled:disabled on CommandPaletteItem
@select="fn"@select="(value, event) => fn(value)"

select now carries two arguments: the item's value, and the click that picked it. Call event.preventDefault() to keep the palette open.

Filtering is included

The old palette filtered nothing; it rendered groups as given. The new one filters against the query by default, so a call site that never filtered starts narrowing its list. That is usually the fix, not a break.

Set :filterable="false" when a server search already decided what matches, then refetch on update:query yourself. It is the same word Combobox and MultiSelect use.

An item filters on the text of its default slot. #prefix and #suffix are left out, so a trailing shortcut hint never becomes searchable.

Mod+K moves to the caller

The palette registered Mod+K itself and carried enabled: () => !document.activeElement?.closest('.ProseMirror'), hardcoding knowledge of the rich-text editor. Both are gone. Register the shortcut where the app knows the answer:

js
useKeyboardShortcut({
  combo: 'Mod+K',
  description: 'Open command palette',
  allowInInput: true,
  handler: () => (open.value = true),
})

Keep allowInInput: true. The old palette set it, and useKeyboardShortcut defaults it to false, so leaving it out gives you a Mod+K that stops working the moment a field has focus.

If you are on 1.0.0-beta or older

Two earlier renames land in the same move. show became open and searchQuery became query, both silent breaks: Vue accepts the unknown prop, so the palette never opens and your query binding never updates. Suite's SheetEditor still binds v-model:show and v-model:searchQuery, so its palette does not open today.

BeforeAfter
v-model:show="show"v-model:open="open"
v-model:search-query="q"v-model:query="q"
@update:searchQuery="onSearch"@update:query="onSearch"

useShortcut is now useKeyboardShortcut

The import fails to resolve, so your build names every call site. The config inside it is a silent break: 14 fields become 10, and the ones that left are dropped without a word.

BeforeAfter
useShortcut(...)useKeyboardShortcut(...)
key: 's', ctrl: truecombo: 'Mod+S'
key: 'z', ctrl: true, shift: truecombo: 'Mod+Shift+Z'
key: 'ArrowUp'combo: 'ArrowUp'
key: '/'combo: 'Slash'
key: '?'combo: 'Shift+Slash'
key: ' 'combo: 'Space'
condition: () => canEdit.valueenabled: () => canEdit.value
triggeredOn: 'hold'delete it; onHold selects hold mode
const { activeShortcuts } = useShortcut(...)returns void
ShortcutConfigKeyboardShortcutConfig
RegisteredShortcut, ActiveShortcutgone; see below
js
// Before
useShortcut([
  { key: 's', ctrl: true, description: 'Save', group: 'View', handler: onSave },
  {
    key: 'z',
    ctrl: true,
    description: 'Undo',
    condition: notReadOnly,
    handler: undo,
  },
  {
    key: 'y',
    ctrl: true,
    description: 'Redo',
    condition: notReadOnly,
    handler: redo,
  },
])

// After
useKeyboardShortcut([
  { combo: 'Mod+S', description: 'Save', group: 'View', handler: onSave },
  { combo: 'Mod+Z', description: 'Undo', enabled: notReadOnly, handler: undo },
  { combo: 'Mod+Y', description: 'Redo', enabled: notReadOnly, handler: redo },
])

ctrl never meant Control. It matched ctrlKey || metaKey, so { key: 's', ctrl: true } fired on ⌘S, on ⌃S and on Win+S alike. Mod+S compares every modifier exactly: ⌘S on macOS, Ctrl+S elsewhere. Those two extra trigger paths stop. Register Ctrl+S as well if you need Control+S on a Mac. The grammar has no token for the Windows key, so Win+S cannot come back. Write Ctrl only where you mean Control on a Mac too.

A combo spells its modifiers in one order, Mod+Ctrl+Alt+Shift+<Key>, and a letter uppercase. The type accepts no other spelling.

Punctuation and digits take a key name

+ separates the parts of a combo, so it cannot also be a key. Name the key instead:

BeforeAfter
key: '+'combo: 'Shift+Equal' (or 'Plus' for the keypad key)
key: '='combo: 'Equal'
key: '-'combo: 'Minus'
key: '/'combo: 'Slash'
key: '\\'combo: 'Backslash'
the backtick keycombo: 'Backtick'
key: '1'combo: 'Digit1'
key: '!'combo: 'Shift+Digit1'

Digits and punctuation now match event.code, so Mod+Shift+Digit1 fires on ⌘⇧1 and on ⌘⇧! alike. A punctuation name means the physical key position, as labelled on a US layout, so Mod+Slash fires on the same key everywhere. Plus is the keypad + alone, the key whose event.code is NumpadAdd. Letters and named keys still match event.key. The old US-layout heuristic that let ? match without declaring Shift is gone: declare the Shift.

TypeScript rejects an unknown combo. A JavaScript call site still passing the v0 shape logs one dev warning and never fires.

Hold shortcuts

ts
// Before
{ key: 'l', ctrl: true, shift: true, triggeredOn: 'hold',
  description: 'Highlight blocks', onHold: on, onRelease: off }

// After
{ combo: 'Mod+Shift+L', description: 'Highlight blocks', onHold: on, onRelease: off }

A hold registration takes no handler. triggeredOn: 'hold' used to fire handler and onHold; if you relied on that, move the work into onHold.

A v0 shortcut that paired a plain handler with your own keyup listener folds into onHold / onRelease too. Delete the listener.

enabled also hides the shortcut

While enabled is false the shortcut is inert and absent from KeyboardShortcutsDialog. condition behaved this way already, undocumented. It is now specified and tested, so read-only modes keep working.

Precedence changed

Two shortcuts on one combo used to run whichever the registry reached first. The last registration that is enabled at the time of the keypress now wins. enabled is resolved first, so a pair with mutually exclusive guards still works unchanged. A real collision warns once per combo in development.

formatShortcutLabel and getActiveShortcuts are gone

Both imports fail at the build. Neither had a consumer. To render a combo, use <KeyboardShortcut :combo="combo" />. To read the registry, use KeyboardShortcutsDialog's default slot.

The types they used, RegisteredShortcut and ActiveShortcut, are gone with them. The dialog's slot gives KeyboardShortcutGroup and KeyboardShortcutEntry instead. An entry carries combo, altCombos, description and group.

KeyboardShortcutsModal is now KeyboardShortcutsDialog

The import fails to resolve, and Vue logs an unknown-component warning for a globally registered <KeyboardShortcutsModal>. Props are unchanged.

BeforeAfter
import { KeyboardShortcutsModal } from 'frappe-ui'import { KeyboardShortcutsDialog } from 'frappe-ui'
<KeyboardShortcutsModal v-model:open="open" /><KeyboardShortcutsDialog v-model:open="open" />
KeyboardShortcutsModalPropsKeyboardShortcutsDialogProps

Two dialog behaviors are worth knowing before you diff its output:

  • Shortcuts that share a group and a description merge into one row, with the other combos after a /. Mod+Shift+Z and Mod+Y, both "Redo", are one row. v0 merged only when the modifiers matched too, so rows that used to be separate now join.
  • A disabled shortcut has no row at all.

The shortcuts codemod

shortcuts-v1 applies both changes above for you. Run it from the app you are migrating:

sh
npx --package frappe-ui@beta shortcuts-v1 --dry-run .

Review the output, then run it without --dry-run:

sh
npx --package frappe-ui@beta shortcuts-v1 .

It renames useShortcut, KeyboardShortcutsModal and ShortcutConfig, folds key and the modifier flags into one combo, and renames condition to enabled. description, group, handler, onHold, onRelease, preventDefault, allowInInput and allowInDialog keep their names and their defaults. The codemod passes them through.

The codemod exits non-zero when it refused a site, on a dry run too. A clean exit means a clean run. Re-running it is safe: a converted object has no key field left to convert, and a refusal repeats until you fix it.

A file with a refused site stays exactly as it was, even when its other shortcuts converted. Half a migration puts a renamed call beside a config with no combo, and v1 throws on the first keypress. Fix the sites the run names, then run again and take the whole file at once.

The same rule decides an object the codemod cannot prove is a config. In a file it would otherwise write, that object is a refusal and the file stays as it was. In a file with nothing else to change, it is advice, and the run exits zero. A file the run names is never written, whichever the line was.

The codemod does not reflow the code it edits. Run your formatter after it.

What it rewrites

The codemod rewrites an object in two places only:

  • Inside a useShortcut(...) or useKeyboardShortcut(...) call, where the name is imported from frappe-ui in the same file.
  • Inside an array or object literal typed ShortcutConfig or KeyboardShortcutConfig, where that type is imported from frappe-ui. An annotation, a satisfies clause and an as cast all count.

Both places name frappe-ui. Nothing else does.

ts
import { useShortcut, type ShortcutConfig } from 'frappe-ui'

// Rewritten: the call is frappe-ui's.
useShortcut({ key: 's', ctrl: true, description: 'Save', handler: save })

// Rewritten: the annotation is frappe-ui's.
const bindings: ShortcutConfig[] = [
  { key: 'k', ctrl: true, description: 'Palette', handler: open },
]

// Rewritten: a clause after the literal names the same type.
const save = {
  key: 's',
  ctrl: true,
  description: 'Save',
  handler: onSave,
} satisfies ShortcutConfig

// Left alone: nothing here says frappe-ui.
const menu = [
  { key: 'delete', label: 'Delete', condition: canDelete, handler: remove },
]

Field names alone are never evidence. key, description, condition, group and handler are frappe-ui's own option vocabulary too: a ComboboxCustomOption is { type, key, label, description, condition, onClick } and a ComboboxGroupedOption is { key, group, hideLabel, options }. An app that hands a config array to its own composable, or builds one with .map(), writes the same names for something else.

The cost is a registration written away from its call and with no annotation. The codemod does not rewrite it. It names it instead, with the combo to write.

ts
import { useShortcut } from 'frappe-ui'

// Refused: the call renames, so writing this file would leave the array on v0.
const bindings = [{ key: 's', ctrl: true, description: 'Save', handler: save }]
useShortcut(bindings)

Clear that line in one of three ways: write the combo by hand, annotate the array with frappe-ui's config type so the next run can prove it, or migrate the whole file by hand and leave the run nothing to write.

Both type names count as proof. Write ShortcutConfig[] while the app is still on v0, which is where the codemod runs, and KeyboardShortcutConfig[] once you have bumped. A lone object takes the same name without the []. Each refused line names the type for its own shape.

Punctuation keys are never converted

This is the reason to run a codemod instead of a grep. + is both the combo separator and a key, so { key: '+', ctrl: true } written by hand becomes 'Mod++', which splits into ['Mod', '', ''] and never fires. Nothing fails: the build passes, the types pass, and you get one dev-console warning that a production build drops.

So the codemod stops on the site and prints the whole combo to write, with the modifier flags to delete beside the key:

✗ Not converted — 3 sites need a decision:
  src/sheets/useShortcuts.js:L95  key '=' is punctuation ... Write `combo: 'Mod+Equal'` by hand. Delete `ctrl` with the `key`.
  src/sheets/useShortcuts.js:L96  key '+' is a shifted character ... Write `combo: 'Mod+Shift+Equal'` by hand. Delete `ctrl` with the `key`.
  src/Commands/index.ts:L196      key '?' is a shifted character ... Write `combo: 'Mod+Shift+Slash'` by hand. Delete `ctrl` with the `key`.

The combo carries the modifiers. A ctrl: true left beside a hand-written combo reaches v1 as an excess property, and no later run mentions it: with no key there is nothing left to refuse.

The name each key takes is in Punctuation and digits take a key name. Plus is the trap: it is the keypad key alone, so { key: '+', ctrl: true } becomes 'Mod+Shift+Equal'. The codemod writes that whole combo on the line, because Mod+Plus would bind a key the user never presses and report a clean run.

Write the combo the line gives you.

Combo reference

Every key name a combo can hold is listed under combo takes the key names the composable fires on. A refused line points here when it cannot build the name for you.

Take the name into combo, never back into key. key is the v0 field, and v0 compared it to KeyboardEvent.key, which reports none of those spellings. A v1 name left in key is still a v0 config, and the next run refuses it again.

Digits convert, and get listed

{ key: '1', ctrl: true, shift: true } becomes { combo: 'Mod+Shift+Digit1' }. A shifted digit (!, @, ...) now resolves to the same combo. That is a behaviour change, so the codemod lists every digit it touched under "Digit keys converted". Read each one.

What it reports but never rewrites

Each of these exits the run non-zero. Fix them by hand.

  • Punctuation and shifted characters. See above.
  • An uppercase key with no shift: true. v0 matched the letter either way and ignored Shift, so { key: 'S' } fired on s and on Shift+S. v1 is exact. Write S, or Shift+S, or register both.
  • A key that is not a plain string, and a modifier flag that is not a literal true / false. v1 has no conditional modifier. A shorthand property counts: { key, ctrl } holds its values somewhere else, so the combo cannot be built from the object.
  • A spread, or a computed name the run cannot read, on a config it proved.{ ...base, handler: save } and { [Keys.SAVE]: 's' } can carry a key or a modifier that never reaches the run. Write the properties out, or convert the object by hand.
  • formatShortcutLabel and getActiveShortcuts. Both are deleted, and their ActiveShortcut and RegisteredShortcut types go with them. See formatShortcutLabel and getActiveShortcuts are gone for what replaces each one. The codemod names all four wherever they appear, comments included.
  • A destructured useShortcut(...) return. v1 returns void; cleanup already runs on unmount.
  • triggeredOn: 'hold' next to a handler. v0 fired both and v1 will not, so the run cannot pick a side. To keep the hold, delete the handler. To keep the press, delete triggeredOn: 'hold' and the hold callbacks with it. See Hold shortcuts.
  • onHold or onRelease without triggeredOn: 'hold'. v0 gated both on 'hold', so the callback never fired. In v1 the callback itself selects hold mode, so it starts firing. Delete it, or add triggeredOn: 'hold' to keep it on purpose — the next run converts that pair.
  • A vi.mock('frappe-ui', ...) keyed on useShortcut. The file is left alone: the captured configs carry key / ctrl and the assertions read them. Rename the mock key, write the combos and move the assertions by hand.
  • An object that reads like a config, where the run cannot prove it, in a file it would otherwise write. A key string beside a handler or a condition is enough to name it, with or without a modifier. See "What it rewrites".

What it lists without failing the run

None of these fails the run. Read them and decide if the code wants a rewrite.

  • Every digit it converted. See above.
  • A v0 key spelling that never matched, such as 'esc', 'up', 'spacebar' or 'space'. v0 compared event.key, which never reports those, so the shortcut never fired. The combo does fire, so the shortcut is live now. 'space' is on the list because event.key gives ' ' for the space bar; Space is that key's event.code.
  • An object that reads like a config, in a place the run cannot prove, in a file with nothing else to change. The line gives the combo to write. Take it if the object is a registration: v1 throws on a config with no combo. An object that carries an option-only name, such as label, options, onClick or type, is never listed, and neither is one your own composable receives.
  • Your own useShortcut or ShortcutConfig, imported from your module or declared in the file. That name is left as it is, and the rest of the file still migrates.
  • A possible hand-rolled hold: a shortcut registration and a manual keyup listener in the same file. The pair may fold into one registration, as Hold shortcuts describes. Only you can say which half is which. This is a guess: an unrelated keyup listener matches too, and no edit would clear it, so it never fails the run.

It never renames your own composable

useShortcut is renamed only where the file imports it from the frappe-ui barrel. A fork imported from your own module, or declared in the same file, keeps its name, and the run says so and moves on. helpdesk ships a useShortcut of its own in composables/shortcuts.ts; every page that uses that one is left as it is. crm, lms and suite each ship a local useKeyboardShortcuts, one character from the new name; those are untouched too.

A fork silences its own calls, not the whole file. A useKeyboardShortcut(...) imported from frappe-ui a few lines below your own useShortcut still converts, and the objects your composable receives are still left alone.

A rename also stays inside code. A name in a string, a module specifier or a comment keeps its spelling. In a .vue template only three places rename: the tag, a bound attribute value such as :is="...", and a mustache. A bound value and a mustache hold an expression, so a reference in one migrates and a quoted string in one does not. class="useShortcut", useShortcut and template prose all stay as they are.

KeyboardShortcut

The deprecated shortcut prop, and the unused meta / ctrl / shift / alt boolean props, are removed. Use combo — a string like "Mod+Shift+K".

BeforeAfter
<KeyboardShortcut shortcut="Mod+K" /><KeyboardShortcut combo="Mod+K" />
<KeyboardShortcut ctrl shift>K</KeyboardShortcut><KeyboardShortcut combo="Mod+Shift+K" />

Both are silent breaks at runtime: the removed props fall through onto the rendered <span> as plain HTML attributes, so shortcut="Mod+K" renders an empty chip and ctrl shift renders the key with no modifier glyphs. Only a type-check names the call sites.

combo takes the key names the composable fires on

The display used to accept a second, looser vocabulary. It is gone: a chip for a combo that can never fire is the failure this family exists to remove. An unknown token renders as written, so <KeyboardShortcut combo="Cmd+K" /> draws the word "Cmd" next to the K.

This is a silent break. combo stays typed string, because callers compute it, so no type-check names the call sites. The chip warns once per token in development and says nothing in production.

BeforeAfter
Cmd, Command, , MetaMod
ControlCtrl
Option, Opt, Alt
Shift
Win, Windowsnothing; the grammar has no Windows key
EscEscape
ReturnEnter
DelDelete
Up, Down, Left, RightArrowUp, ArrowDown, ArrowLeft, ArrowRight
=Equal
F13 and abovenothing; the grammar stops at F12
vue
<!-- Before -->
<KeyboardShortcut combo="Cmd+K" />
<KeyboardShortcut combo="Ctrl+Esc" />
<KeyboardShortcut combo="Option+Up" />

<!-- After -->
<KeyboardShortcut combo="Mod+K" />
<KeyboardShortcut combo="Ctrl+Escape" />
<KeyboardShortcut combo="Alt+ArrowUp" />

Grep every combo on the component, including bound values. The whole vocabulary is Mod, Ctrl, Alt and Shift, a letter, F1 to F12, and these key names: Escape, Enter, Space, Tab, Insert, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, Digit0 to Digit9, Plus, Minus, Equal, Slash, Backslash, Backtick, Comma, Period, Semicolon, Quote, BracketLeft, BracketRight. useKeyboardShortcut reads the same grammar, so a combo you register is a combo you can draw.

useIcons now reaches bg mode

bg chips ignored the prop and always drew an icon for the arrow, Enter, Backspace and Delete keys. :use-icons="false" now drops those icons in both modes and draws the glyph instead. The default is true, so a chip that never set the prop is unchanged.

The root's role is img

role="note" on the root becomes role="img" when combo is set, and no role at all without one. A labelled img replaces its subtree, so a screen reader reads "Shortcut Control + Backspace" once instead of meeting every chip. Update any test or stylesheet that selects [role='note'].

matchesShortcut is no longer exported

import { matchesShortcut } from 'frappe-ui' fails at the build. Its own doc comment said it was exported for unit tests only. Register a shortcut with useKeyboardShortcut instead of matching a KeyboardEvent by hand.

App shells and ScrollArea

ScrollBar is no longer exported

import { ScrollBar } from 'frappe-ui' fails at the build. There is no replacement. ScrollArea draws its own scrollbars, and ScrollBar only ever worked inside reka-ui's ScrollAreaRoot, which frappe-ui does not export. Use ScrollArea with orientation:

vue
<!-- After -->
<ScrollArea orientation="both">
  <WideTable />
</ScrollArea>

orientation="both" renders one scrollbar per axis. ScrollBarProps is gone with the component.

useSheetDrag is no longer exported

useSheetDrag, UseSheetDrag and UseSheetDragOptions leave the root. Both imports fail loudly. BottomSheet still uses the composable internally and is unchanged, so an app that renders BottomSheet has nothing to do. There is no standalone replacement in 1.0.0: the drag thresholds are fixed constants tuned for that one surface. It can come back when a second surface needs it.

useShellScrolled needs a threshold

threshold is now required, in pixels.

js
// Before — 200px by default
const scrolled = useShellScrolled()

// After
const scrolled = useShellScrolled({ threshold: 12 })

A call with no argument is a type error, so the build catches it. At runtime it warns in development and stays false. The old 200px default suited a long document and nothing else: a header border that appears 200px late reads as a bug rather than as a missing argument.

A page reads the shell it is inside

No syntax changes. useShellScrolled(), and a PageHeader teleporting to its target, now resolve the nearest enclosing shell first, and fall back to the shellScrollContainer registry only when there is no shell above them.

This only changes what you see while two shells are mounted at once — a desktop-to-mobile swap mid-transition, or a test that mounts both. Before, both answers came from the registry, which returns the shell that mounted most recently, so a header could teleport into the wrong frame. shellScrollContainer itself is unchanged and is still the way to reach the scroll element from a router scrollBehavior or any other code outside a component.

DesktopShell takes :scroll="false"

New prop, default true, nothing to migrate. Pass false for a layout whose panes own their own overflow, instead of fighting the shell's page scroll:

vue
<!-- Before -->
<DesktopShell>
  <div class="absolute inset-0 flex h-[calc(100vh-3rem)]">…</div>
</DesktopShell>

<!-- After -->
<DesktopShell :scroll="false">
  <div class="flex min-h-0 flex-1">…</div>
</DesktopShell>

With :scroll="false" the shell has no scroll element, so shellScrollContainer is null and useShellScrolled() stays false. Read the pane's own ScrollArea instead.

Shell slot names stay split

Nothing to change. DesktopShell keeps #rail and #sidebar; MobileShell keeps #nav. The names describe regions, not components, and the two frames have different regions, so there is no shared name to move to.

Color scheme: resolvedColorScheme is a ref

import { resolvedColorScheme } from 'frappe-ui' fails. The same name is now a read-only ref on useColorScheme(), so there is no call to make.

js
// Before
import { resolvedColorScheme } from 'frappe-ui'
const scheme = ref(resolvedColorScheme())
const observer = new MutationObserver(() => {
  scheme.value = resolvedColorScheme()
})
observer.observe(document.documentElement, { attributeFilter: ['data-theme'] })

// After
import { useColorScheme } from 'frappe-ui'
const { resolvedColorScheme } = useColorScheme()
// resolvedColorScheme.value is 'light' or 'dark'

The observer goes with it. The ref follows setColorScheme and follows the OS setting while the preference is system, which is what the observer was watching for. ColorScheme and ResolvedColorScheme stay exported.

The rename resolvedColorScheme to getResolvedColorScheme is internal: the function is no longer part of the package surface at all. No codemod can do this one, because a call has to become a .value read.

Call useResolvedColorScheme() instead when the page you are migrating does not own data-theme: an app that applies its own theme before paint, or a page inside a host shell. It is the same value and it writes nothing, where useColorScheme() applies the saved preference on its first call and would make a second writer of the attribute.

js
import { useResolvedColorScheme } from 'frappe-ui'
const scheme = useResolvedColorScheme()
// scheme.value is 'light' or 'dark'

toggleColorScheme() moves off what is on screen

toggleColorScheme() used to read the stored preference. Under system on a dark OS it wrote dark, which the page was already painted in, so the first press did nothing visible. It now reads the resolved value, so one press always moves: system on a dark OS goes to light.

Apps that wrote their own toggle for this reason can drop it.

--mobile-header-height is removed

PageHeaderMobile is 52px tall, fixed. It no longer reads --mobile-header-height, so setting that variable does nothing to the library header. Delete the declaration, or keep it if your own CSS reads it — the name is yours now.

There is no replacement hook. A header of another height is PageHeaderBase with your own class:

vue
<PageHeaderBase class="flex h-16 items-center border-b px-3">
  <MyToolbar />
</PageHeaderBase>

PageHeaderMobile family: slot names

PageHeaderMobile's #left/#right and PageHeaderMobileTitle's #icon are renamed to the shared #prefix/#suffix vocabulary (see PHILOSOPHY.md P6). This is a silent break: Vue drops content passed to an unknown slot name with no error or warning — the back button, title icon, or trailing action just stops rendering.

BeforeAfter
PageHeaderMobile #left#prefix
PageHeaderMobile #right#suffix
PageHeaderMobileTitle #icon#prefix
vue
<!-- Before -->
<PageHeaderMobile title="Space">
  <template #left><BackButton /></template>
  <template #right><Button icon="lucide-more-horizontal" /></template>
</PageHeaderMobile>
<PageHeaderMobileTitle title="Space">
  <template #icon><SpaceIcon /></template>
</PageHeaderMobileTitle>

<!-- After -->
<PageHeaderMobile title="Space">
  <template #prefix><BackButton /></template>
  <template #suffix><Button icon="lucide-more-horizontal" /></template>
</PageHeaderMobile>
<PageHeaderMobileTitle title="Space">
  <template #prefix><SpaceIcon /></template>
</PageHeaderMobileTitle>

Grep for #left, #right, and #icon on these two components specifically — other components (e.g. ListView's footer) have their own unrelated #left/ #right slots that are unaffected.

FrappeUIProviderProps is deleted

The type was exported but never wired to the component, so it described props FrappeUIProvider did not accept. It is removed in 1.0.0 with no replacement. This one is loudimport type { FrappeUIProviderProps } from 'frappe-ui' fails the type-check. FrappeUIProvider itself is unchanged and still exported.

Charts

Only for apps already on frappe-ui/charts from 1.0.0-beta.42 or later — the subpath did not exist before that. The family is new in v1, so an app coming from v0 has nothing to migrate here; the older config-object family is covered by the "Charts (v1)" section above.

Eight charts renamed their mark emit to select, collapsing six old names into one. This is a silent break: Vue attaches a listener for an emit the component no longer declares as a plain attribute, so the handler stops firing with no error and no warning. The payload is unchanged, so only the name moves.

BeforeAfter
AreaChart @datapoint-click@select
BarChart @datapoint-click@select
LineChart @datapoint-click@select
DonutChart @slice-click@select
FunnelChart @stage-click@select
HeatmapChart @cell-click@select
SankeyChart @link-click@select
ScatterChart @point-click@select
vue
<!-- Before -->
<BarChart :data="rows" x="warehouse" :y="['picked']" @datapoint-click="open" />
<DonutChart
  :data="rows"
  category="channel"
  value="sessions"
  @slice-click="open"
/>
<SankeyChart
  :data="rows"
  source="from"
  target="to"
  value="amount"
  @link-click="open"
/>

<!-- After -->
<BarChart :data="rows" x="warehouse" :y="['picked']" @select="open" />
<DonutChart :data="rows" category="channel" value="sessions" @select="open" />
<SankeyChart
  :data="rows"
  source="from"
  target="to"
  value="amount"
  @select="open"
/>

select also fires on Enter and Space over the plot's keyboard cursor, which is why the old names had to go — they described the mouse, not the behavior.

Grep for datapoint-click, slice-click, stage-click, cell-click, link-click and point-click, and for the camelCase spellings in render functions and h() props.

Numbers follow the page's language

Charts used to print every number in en-US whatever the page was in. They now read <html lang>, the same attribute dir already reads. Another silent break: nothing errors, the grouping and decimal marks just change.

A page with no lang, or a malformed one, prints exactly what it printed before. A page that declares de-DE gets 1.234,5 where it used to get 1,234.5. A page that flips lang after a chart mounts reprints it. If a chart must stay in one locale whatever the page says, pass your own format — the axis, the tooltip and the labels all run through it.

Numbers only. Dates and time-axis labels still print in English, whatever the page declares. Pass xAxis.format to print them in another language.

FunnelChart always prints its percentages

showPercentages is removed. A funnel always printed its counts, so turning the prop off took the conversion rate away and put nothing in its place. A silent break, because the prop defaulted to true: Vue passes the unknown prop through as an attribute and the percentages come back.

vue
<!-- Before: counts only -->
<FunnelChart
  :data="rows"
  category="stage"
  value="count"
  :show-percentages="false"
/>

<!-- After: counts and conversion rates, always -->
<FunnelChart :data="rows" category="stage" value="count" />

Grep for show-percentages and showPercentages.

DonutSliceEvent.name is the slice's identity

@select on DonutChart used to pass the printed slice name as name. It now passes the identity, and what it printed moves to label. Both are strings, so a handler that keyed on name type-checks and keeps running on a different value: the collapsed tail is __others__ (the exported OTHERS_KEY), and a label a second row repeats is deduplicated to "A (2)". A slice whose label is unique and not collapsed passes what it always passed.

ts
// Before: name carried what the slice printed
function open(slice: DonutSliceEvent) {
  showTitle(slice.name)
}

// After: label prints, name identifies
function open(slice: DonutSliceEvent) {
  showTitle(slice.label)
}

Grep for @select handlers on DonutChart and read what they do with .name.

A sparkline type: 'line' draws the stroke alone

NumberCardSparkline.type still accepts 'line', so a card that names it compiles unchanged and draws something else: the old 'line' was a stroke over a fill, which the family calls an area, and 'line' is now the stroke alone. For the old drawing, say 'area', or drop the key — 'area' is the default.

Grep for sparkline objects carrying type: 'line'.

A value that does not plot is dropped

One policy across the family, and every part of it is silent. A funnel stage whose count is missing, unreadable or negative is dropped rather than drawn at 0, so FunnelStage.index and the conversion rates count the stages that remain. A row whose x cannot be read as a date is dropped from a time axis, and the rows sort by time. Bar, line and area draw the empty state where they used to draw bare axes, when no visible series carries a number at any row. select no longer fires on Enter for a cell the plot drew no mark for.

Grep for FunnelChart data that can carry a blank or negative value, and for #empty slots on bar, line and area, which now render for a y key no row carries.

The loud ones

The build or the type-check reports the rest.

  • ChartTheme is ChartTokens, useChartTheme is useChartTokens and it returns { tokens } instead of { theme }. ChartTokens.splitLine is gridline, after the --chart-gridline variable it reads.
  • The ColorScheme type this subpath exported is gone, and so is its replacement here: import ResolvedColorScheme from the package root.
  • formatValue, formatDate, formatLabel, formatPercent, formatAxisValue, currentColorScheme, resolveChartTheme and OTHERS_LABEL are no longer exported. OTHERS_KEY stays.
  • showValues on HeatmapChart and showInlineLabels on DonutChart are both showDataLabels. Axis charts take it at the chart level too, so one prop replaces one seriesConfig entry per series.
  • ChartTooltipItem.kind is required, and 'column' is now 'context'. An item built by hand needs kind: 'series'.
  • The #tooltip slot passes rows, a list, in place of row on every chart. Read rows[0] where you read row. FunnelChart's stage slot prop is gone: its percentOfFirst and percentOfPrevious are items named ofFirst and ofPrevious. ChartTooltip takes rows as a prop too.
  • DonutChart's #center slot passes { label, value, formattedValue, percent }. value and percent are numbers now; print formattedValue where you printed value.
  • NumberCardSparklineType is gone; NumberCardSparkline.type takes a ChartMark.
  • ChartDatapointEvent.dataIndex and FunnelStageEvent.index are gone. Read the row, which every event carries.
  • seriesName is name on ChartDatapointEvent and ScatterPointEvent, matching every other payload. FunnelStageEvent carries a name as well, the category value behind the printed label.
  • ChartExposed.chart is ECharts | undefined, not ComputedRef<ECharts | undefined>. The runtime is unchanged — Vue always unwrapped the computed — so only code that named the old type moves: plot.value?.chart?.getDataURL(...) reads the same as before.
  • ChartContainer's plotLabel is yAxisTitle, plotLabelSecondary is y2AxisTitle and plotLabelPlacement is axisTitlePlacement, with the PlotLabelPlacement type now AxisTitlePlacement. They title the value axes, which every chart names yAxis.title and y2Axis.title. Only a hand-composed ChartContainer passes them; the built-in charts set them from those props.
  • SeriesStyle.lineWidth is removed. Every line draws at the library's own weight; seriesConfig[key].echartOptions = { lineStyle: { width: 3 } } sets another.
  • fillOpacity is removed, chart-level and per-series. The library's fill rules stay: a free area fades out towards the axis, a banded one is solid. seriesConfig[key].echartOptions = { areaStyle: { opacity: 0.25 } } sets an alpha on one series.
  • seriesConfig[key].lineType is dashed?: boolean. lineType: 'dashed' is dashed: true, and 'dotted' has no replacement — a dotted line paints the gridlines' own texture on a mark. A dashed series now draws the dash the reference lines use.
  • SankeyChart's orient is vertical, a boolean, and the SankeyOrient type is no longer exported. orient="vertical" is vertical, and orient="horizontal" is the default. nodeAlign is unchanged.
  • paletteColors(name, tokens, count) is paletteColors(palette, tokens, count, fallback?). The first argument now takes what the palette prop takes — a ramp name, an explicit list of colors, or nothing — and fallback names the ramp to read when it is nothing, defaulting to 'sequential'. A call passing a ramp name is unchanged.
  • NumberCard's precision and compact are gone. Pass format, and deltaFormat for the delta: :compact="true" becomes :format="(v) => Intl.NumberFormat(undefined, { notation: 'compact' }).format(v)" and :precision="1" becomes :format="(v) => v.toFixed(1)".

Toast: the legacy object form is removed

toast({ title, text }) no longer works, and nothing tells you. The object is handed to sonner as the message. Sonner expects a string, a component or a VNode, so the toast renders empty or wrong instead of throwing.

js
// Before
toast({ title: 'Saved', text: 'Your changes are live.', type: 'success' })

// After
toast.success('Saved', { description: 'Your changes are live.' })

position was already ignored per toast. Set it once on <ToastProvider>.

js
// Before
toast({ title: 'Copied', position: 'bottom-right' })

// After — position is global
toast('Copied')

A grep for toast( will not find these reliably. Grep for the keys instead: title:, text: and message: inside a toast( call.

The three named shims go at the same time, and those fail loudly:

js
toast.create({ message: 'Loading…' }) // → toast.message('Loading…')
toast.remove(id) // → toast.dismiss(id)
toast.removeAll() // → toast.dismiss()

toast.create({ closable: false }) mapped to three sonner flags. Write them out:

js
// Before
toast.create({ message: 'Uploading…', closable: false })

// After
toast.message('Uploading…', {
  duration: Infinity,
  closeButton: false,
  dismissible: false,
})

toast.create also took duration in seconds, where sonner takes milliseconds, and treated duration: 0 as "never dismiss". Multiply by 1000, and write Infinity where you meant persistent.

Toast: description now renders limited inline HTML

description is sanitized and rendered like the message, with the same safelist (a, em, strong, i, b, u). It used to render as plain text.

This is silent. A description holding a < that is not one of those six tags loses those characters, with no warning:

js
// Before — rendered literally: Set <Button> variant
toast('Heads up', { description: 'Set <Button> variant' })

// After — DOMPurify strips <Button>: Set  variant
// Escape it, or drop the angle brackets:
toast('Heads up', { description: 'Set &lt;Button&gt; variant' })

Descriptions that are components, VNodes or render functions are untouched.

TabButtons: class on an option → data-value

class on a TabButton option object no longer applies. In JavaScript nothing warns and nothing fails: the tab simply loses its styling. Style the tab from CSS through the new data-value hook instead.

vue
<!-- Before -->
<script setup>
const tabs = [
  { label: 'Open', value: 'open', class: 'text-red-600 font-bold' },
  { label: 'Closed', value: 'closed' },
]
</script>

<template>
  <TabButtons :buttons="tabs" v-model="tab" />
</template>
vue
<!-- After -->
<script setup>
const tabs = [
  { label: 'Open', value: 'open' },
  { label: 'Closed', value: 'closed' },
]
</script>

<template>
  <TabButtons class="my-tabs" :options="tabs" v-model="tab" />
</template>

<style scoped>
.my-tabs :deep([data-slot='tab-button'][data-value='open']) {
  color: var(--ink-red-3);
  font-weight: 600;
}
</style>

Two related names go with it:

  • NativeButtonClass is no longer exported. The import fails.
  • customClass is gone from the #prefix and #suffix slot props. Destructuring it fails; spreading it silently yields nothing.

The composed Tabs family needs no change. You write the <TabTrigger> yourself there, so a class goes on the element directly.

ThemeSwitcher — moved to frappe-ui/experimental

ThemeSwitcher is not core v1 surface. It moves out of the root export to frappe-ui/experimental (P14 — no stability promise) and parks there, still deprecated, while apps migrate. The import fails at the root; switch the subpath:

ts
// Before
import { ThemeSwitcher } from 'frappe-ui'

// After
import { ThemeSwitcher } from 'frappe-ui/experimental'

ThemeSwitcherProps moves the same way. Nothing about the component changed, only where it is imported from.

If you want off the deprecated component

The replacement is behavioral, not visual. ThemeSwitcher renders a group of theme preview cards. Select bound to useColorScheme gives you the same control in a dropdown, so an app that wants the cards keeps its own markup:

vue
<!-- Before -->
<script setup>
import { ThemeSwitcher } from 'frappe-ui'
</script>

<template>
  <ThemeSwitcher />
</template>
vue
<!-- After -->
<script setup>
import { Select, useColorScheme } from 'frappe-ui'

const { colorScheme, setColorScheme } = useColorScheme()
const options = [
  { label: 'Light', value: 'light' },
  { label: 'Dark', value: 'dark' },
  { label: 'System', value: 'system' },
]
</script>

<template>
  <Select
    :model-value="colorScheme"
    :options="options"
    @update:model-value="setColorScheme"
  />
</template>

Moving the import is the smaller change and keeps the current UI. Take this rewrite only when you want off the deprecated component.

Base component props

Run base-props-v1 over Vue files to migrate statically named component tags. Start with a dry run so globally registered app components that reuse these names are easy to spot:

sh
npx --package frappe-ui@beta base-props-v1 --dry-run src
npx --package frappe-ui@beta base-props-v1 src
  • Icon name remains supported. icon is the canonical spelling and takes precedence when both are present; the codemod can normalize single name props when desired.
  • Replace Progress intervals and intervalCount with one numeric intervals prop. For example, intervals :interval-count="steps.length" becomes :intervals="steps.length".
  • Pass only a string or number to Badge label; use its default slot for rich content.
  • DividerAction now accepts the shared Button fields, including theme, variant, size, and icons.
  • Replace Divider position with align.

Progress labels and hints now render independently. A #hint slot no longer needs a label or hint prop to make its row appear.

FAQ

Will my CSS break? In two ways. Where component structure changed, components expose data-* hooks (data-slot, data-state, data-size, data-variant) — audit selectors that targeted tags or classes. Separately, the token vocabulary moved: removed radius aliases and the shifted ink scales emit no CSS at all, with no build or type error. Run the token codemod before you audit anything by hand.

Do I have to run the codemods? Run tokens-v2 if you use Tailwind utilities from the frappe-ui preset. Run shortcuts-v1 if you register keyboard shortcuts — it also catches the punctuation keys that a hand migration breaks in silence. Run editor-v1 if you use EditorFixedMenu. base-props-v1 handles the Icon, Progress, and Divider changes above. Run destinations-v1 and navigation-v1 for navigation changes, overlays-v1 for overlays and pickers, and list-v1 for the List family. Review any sites the codemods report before completing the hand edits named in other family sections.

Report bugs: file an issue with the v1-beta label. Include the component name, before/after code, version, and a repro.