# Migration

> Upgrading between breaking Lattice UI releases, with the exact code changes each one requires.

Source: https://docs.astra-void.xyz/lattice-ui/reference/migration/

import { LATTICE_VERSION } from "@/lib/lattice-version";

Lattice UI is pre-1.0, so `0.x` minors may break. This page carries the code changes each breaking release needs; [Releases](https://docs.astra-void.xyz/lattice-ui/reference/releases.md) carries the summaries and the reasoning.

## 0.8.0 → 0.8.1

No work. A patch with no API change: `asChild` recognises UI modifier siblings under browser React as well as in Roblox, `UIShadow` joins the modifier set, and re-parented modifiers no longer collide keys with the element's own children. `npx lattice-ui upgrade` and carry on.

The one thing worth knowing is *why* there were two spellings to get wrong, because it decides which renderers 0.8.0 was broken under. `@rbxts/react` rewrites a host tag to its Roblox class name before it builds the element, so in Roblox `<uicorner />` reaches `Slot` as `"UICorner"`. A browser React renderer — what the previews on this site run on — leaves the tag as written. 0.8.0 matched the first spelling only. If you ship to Roblox, 0.8.0 already worked; if you render Lattice primitives under browser React, `asChild` failed there on every styled subtree until this release.

## 0.7.x → 0.8.0

A small release with one shape change: every motion host that was a `CanvasGroup` is now a `Frame`. Nothing about placement, presence timing, exit-before-unmount, dismissal boundaries, focus scoping or layering moves.

### 1. The `CanvasGroup`-only props stop compiling

`Dialog.Content`, `Popover.Content`, `Tooltip.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content` and `Toast.Root` forward props onto a `Frame` now, so `GroupTransparency` and `GroupColor3` are compile errors on those parts. Both were the only reason to know the host was a `CanvasGroup` in the first place.

The reason to change it: a `CanvasGroup` flattens its whole subtree into one offscreen composited layer, so children could not carry their own transparency — and every menu and tooltip paid for that layer whether or not anything faded.

### 2. A `GroupTransparency` fade becomes a `BackgroundTransparency` fade

Swap the recipe for its `Frame` counterpart:

| Was | Now |
| --- | --- |
| `createCanvasGroupRevealRecipe()` | `createSurfaceRevealRecipe()` |
| `createCanvasGroupPopperEntranceRecipe(placement)` | `createPopperEntranceRecipe(placement)` |

Both canvas-group recipes stay exported, for a `canvasgroup` you slot in yourself through `asChild`. `createToastRevealRecipe` already fades `BackgroundTransparency` to match its new host.

`BackgroundTransparency` fades one instance's own background, not its children — so labels and icons inside a surface stay opaque while the surface fades. Fade them alongside it (`TextTransparency`, `ImageTransparency`), or bring the composited layer back deliberately:

```tsx title="Opt back into a one-layer fade"
import { createCanvasGroupRevealRecipe } from "@lattice-ui/react-motion";

<Popover.Content asChild transition={createCanvasGroupRevealRecipe()}>
  <canvasgroup>{/* … */}</canvasgroup>
</Popover.Content>
```

### 3. `Dialog.Content` is the one part with no drop-in replacement

Its motion host spans the whole layer, so animating `BackgroundTransparency` there paints a full-screen rectangle rather than fading the panel. Two ways out, and `Dialog.Content` accepts `asChild` as of this release specifically to make the second one possible:

- Give the content a `Position`-only config for the rise and put the fade on the overlay dim or on your own panel elements.
- Pass `asChild` with your own `canvasgroup` and `createCanvasGroupRevealRecipe()`, which fades the surface as one layer the way the primitive used to. The outside-press boundary moves down to that element's first host child — the same panel a plain dialog renders directly under `Content` — and the dialog still owns `Size` and `Visible` on whatever element it renders.

### 4. `asChild` starts working on Vela-styled subtrees

Not work, a fix to stop working around. The `Slot` runtime matched UI modifiers by their JSX tag, but `@rbxts/react` rewrites a host tag to its Roblox class name before it builds the element — `<uicorner />` arrives as `"UICorner"` — so every modifier missed the lookup and counted as a second target candidate. Any subtree carrying a `UICorner`, `UIPadding` or `UIListLayout` sibling, which is what a Tailwind-style `className` transform emits, failed with "expected exactly one child element besides any UI modifiers". Delete whatever you did to avoid it.

In Roblox, that is the whole story. Under a browser React renderer the tag survives as written, and this release matched the class name only — see [0.8.0 → 0.8.1](#080--081), which recognises both.

### Upgrade checklist

1. `npx lattice-ui upgrade`.
2. Typecheck. Every error is a `GroupTransparency` or `GroupColor3` on a part that is now a `Frame`.
3. Swap each canvas-group recipe for its surface counterpart, and decide per surface whether the children should fade with it.
4. Re-do the dialog's reveal — position on the content, fade on the overlay, or a slotted `canvasgroup`.

## 0.6.x → 0.7.0

0.7.0 is the largest breaking release so far. It strips every primitive down to behavior: colors, fixed sizes, font sizes, literal text and decorative children are gone. In exchange, any prop the underlying instance accepts now forwards straight through, so styling no longer requires `asChild`.

> **Expect a visual diff, not a broken build**
>
> Most of this release does not fail to compile — it renders differently. A dialog that looked finished on 0.6 comes up as an invisible overlay and an unstyled panel on 0.7, because the appearance it was relying on came from the primitive. Budget a pass over every surface, not just the ones the compiler flags.

### 1. Every primitive renders unstyled

Primitives now set behavior plus the minimum needed to neutralize Roblox's own instance defaults — a bare `textbutton` renders an opaque grey box labelled "Button", so parts still set `BackgroundTransparency = 1`, `BorderSizePixel = 0`, `Text = ""` and friends. Everything past that is yours.

Geometry the primitive *computes from state* is still owned by the primitive: progress fill ratios, slider thumb travel, popper-driven position, scroll thumb size, and the full-screen sizing that makes an overlay actually cover the screen.

Because props now forward, the fix is usually to move styling onto the part itself rather than reach for `asChild`:

```tsx title="0.6 — appearance came from the primitive"
<Dialog.Overlay />
```

```tsx title="0.7 — pass the appearance you want"
<Dialog.Overlay
  BackgroundColor3={Color3.fromRGB(0, 0, 0)}
  BackgroundTransparency={0.5}
/>
```

`asChild` is still there, and still the right tool when you need a *different instance class* than the part renders, or when the element comes from elsewhere.

### 2. Forwarded props are type-checked

Each part checks forwarded props against the instance it actually renders. Passing a prop that instance does not accept is now a compile error instead of being silently dropped.

That means you need to know what each part renders. `Dialog.Overlay` is a `textbutton`; the content and surface parts — `Dialog.Content`, `Menu.Content`, `Tooltip.Content`, `Popover.Content` and friends, `Toast.Root`, `Toast.Viewport`, `Menu.Group` — are all `frame`s. Passing `TextColor3` to a `frame` will not compile.

### 3. Motion no longer has defaults

No **presence** motion runs unless you ask for it. Presence timing is unchanged — content still stays mounted until an exit transition finishes — but with no `transition` the transition is instant.

Motion that follows a value the primitive computes is unaffected: `Progress.Indicator` still settles its fill by default, and the slider thumb, slider range and switch thumb still follow their values. That motion is behavior, not decoration, so it stayed.

```tsx title="0.6 — animated with no configuration"
<Dialog.Content>{/* ... */}</Dialog.Content>
```

```tsx title="0.7 — pass the transition you want"
import { createPopperEntranceRecipe } from "@lattice-ui/react-motion";

<Menu.Content transition={createPopperEntranceRecipe("bottom")}>
  {/* ... */}
</Menu.Content>
```

The recipes that used to be applied for you are all still exported from `@lattice-ui/react-motion`, unchanged. Passing one back explicitly reproduces the old feel exactly:

| Part | Recipe that used to be its default |
| --- | --- |
| `Dialog.Content` | None that still fits — see below |
| `Menu.Content`, `ContextMenu.Content`, `Combobox.Content`, `Tooltip.Content`, `Popover.Content`, `Select.Content` | `createPopperEntranceRecipe(placement)` |
| `Accordion.Content`, `Tabs.Content` | `createSurfaceRevealRecipe()` |
| `Checkbox.Indicator`, `RadioGroup.Indicator` | `createIndicatorRevealRecipe(size)` |
| `Toast.Root` | `createToastRevealRecipe()` |

Those hosts all render a `frame` now, so the fade reaches the surface's own background and not the labels inside it. Fade those with it, or pass `asChild` with your own `canvasgroup` and the canvas-group variant of the recipe.

The popper recipes took the **resolved** placement, so the motion originated from the side the surface actually landed on after any flip. Read it from `usePopper` if you want that behavior back; passing no placement animates from a fixed side.

`Dialog.Content` is the one part whose old default needs a change to pass back. Its motion host is a `frame` rather than a `canvasgroup`, and a `frame` has no property that fades its descendants, so `createCanvasGroupRevealRecipe()` has nothing to write `GroupTransparency` to — unless you pass `asChild` with your own `canvasgroup`, which makes that element the motion host and the recipe fit again. Otherwise give the content a `Position`-only config for the rise and fade the overlay dim or your own panel elements — [Fading a dialog](https://docs.astra-void.xyz/lattice-ui/components/dialog.md#fading-a-dialog) has both patterns.

Prefer these over a hand-written config. Each one declares the correct **target contract** — which properties motion is allowed to own — so it cannot fight your layout. A `PresenceMotionConfig` written by hand needs `reveal`/`exit` as `{ values, intent }`, not bare properties:

```tsx title="The config shape, if you do write one"
transition={{
  initial: { Position: UDim2.fromOffset(0, 8) },
  reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.12 } },
  exit: { values: { Position: UDim2.fromOffset(0, 8) }, intent: { duration: 0.096 } },
}}
```

See [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) for which parts take a `PresenceMotionConfig` and which take a `ResponseMotionConfig`, and [Motion recipes](https://docs.astra-void.xyz/lattice-ui/guides/motion-recipes.md) for the full catalog.

> **Overlays animate through their own element**
>
> `Dialog.Overlay` does not accept `transition` — an unstyled overlay has nothing to fade. It still owns presence timing, so to animate a dim, put an element inside it (or use `asChild`) and animate that.

### 4. Props removed

| Part | Removed | Do this instead |
| --- | --- | --- |
| `Switch.Root` | `trackColorMode`, `trackOnColor`, `trackOffColor`, `disabledTrackColor` | Style the track yourself; read state from `Switch` context |
| `RadioGroup.Item` | `transition` | Nothing — it only fed the removed color animation |
| `ToggleGroup.Item` | `transition` | Nothing — same |

The `SwitchTrackColorMode` type is gone with them.

### 5. Contract changes per primitive

**`Select.Item` and `Combobox.Item`** no longer render `textValue` as their label. Supply the label as a child. `textValue` still drives `Select.Value` and combobox filtering, so keep passing it when the label is not a plain string.

```tsx title="0.6"
<Select.Item value="apple" textValue="Apple" />
```

```tsx title="0.7"
<Select.Item value="apple" textValue="Apple">
  <textlabel
    BackgroundTransparency={1}
    Size={UDim2.fromOffset(200, 32)}
    Text="Apple"
    TextColor3={Color3.fromRGB(240, 244, 250)}
    TextXAlignment={Enum.TextXAlignment.Left}
  />
</Select.Item>
```

**`Toast.Viewport`** renders its children instead of the toast queue markup it used to hardcode. Map over `useToast().visibleToasts` yourself — the same shape the `asChild` path already required:

```tsx title="0.7"
const toast = useToast();

<Toast.Viewport>
  <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
  {toast.visibleToasts.map((record) => (
    <Toast.Root
      key={record.id}
      onExitComplete={() => toast.finalize(record.id)}
      visible={!record.exiting}
    >
      <Toast.Title>
        <textlabel
          BackgroundTransparency={1}
          Size={UDim2.fromOffset(300, 20)}
          Text={record.title ?? ""}
          TextColor3={Color3.fromRGB(240, 244, 250)}
          TextXAlignment={Enum.TextXAlignment.Left}
        />
      </Toast.Title>
    </Toast.Root>
  ))}
</Toast.Viewport>
```

**`Menu.Group` and `ContextMenu.Group`** no longer render a vertical `UIListLayout` or force `AutomaticSize`. They were the last primitives holding an opinion about how their children lay out. Supply the layout yourself, as `Select.Group` and `Combobox.Group` already required:

```tsx title="0.7"
<Menu.Group>
  <uilistlayout FillDirection={Enum.FillDirection.Vertical} />
  {/* items */}
</Menu.Group>
```

### 6. Things that got easier

Not everything here is work. Three fixes remove workarounds you may currently be carrying:

- **16 leaf parts now render `children`.** Both separators, both text inputs, the labels, descriptions and messages on `TextField` and `Textarea`, `Toast.Title`, `Toast.Description`, `Select.Value`, `Combobox.Value`, `Avatar.Image` and `Dialog.Overlay` previously dropped children unless `asChild` was set, so attaching a `UICorner` or `UIPadding` silently did nothing. Delete any `asChild` you added only to work around that.
- **`asChild` accepts UI modifiers as siblings.** A `UICorner`, `UIPadding` or `UIListLayout` written next to the child — the shape a Tailwind-style `className` transform such as [vela-rbxts](https://docs.astra-void.xyz/vela-rbxts/index.md) emits — is re-parented under the element the props land on, instead of raising "asChild requires a child element". Fragments are looked through; two real candidates are still an error. Note that the 0.7.0 implementation matched modifiers by their JSX tag and therefore never fired in Roblox — that works from 0.8.0, and from 0.8.1 under a browser React renderer too. See [styling with Vela](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela.md).
- **Highlight state is readable.** `useMenuItemContext`, `useContextMenuItemContext`, `useSelectItemContext` and `useComboboxItemContext` return `{ highlighted, disabled }`. `highlighted` reports hover *or* managed focus, which is what you need to render the highlight the primitive used to draw.

### 7. Focus additions

Two new `useFocusNode` options matter if you render your own focus highlights:

- `onFocusChange` fires when managed focus enters or leaves a node — including when the focused node unmounts, so a highlight cannot outlive its widget. Nodes that never become `GuiService.SelectedObject` (`syncToRoblox: false`, or a non-`Selectable` object) previously had no way to know.
- `onActivate` routes activation to the focused node. While a `FocusScope` is active the navigation controller binds `Return`, `KeypadEnter`, `Space` and `ButtonA` and runs the focused node's activation. Opting out leaves the engine's own `Activated` path untouched. `activateFocusedNode()` drives the same path directly.

See [Focus](https://docs.astra-void.xyz/lattice-ui/reference/focus.md) for the full API.

### Upgrade checklist

1. `npx lattice-ui upgrade` to move every `@lattice-ui/*` package to {LATTICE_VERSION}.
2. Typecheck. Fix the forwarded-prop errors — those are props landing on an instance that does not accept them.
3. Remove `trackColorMode` / `trackOnColor` / `trackOffColor` / `disabledTrackColor` from `Switch.Root`, and `transition` from `RadioGroup.Item` and `ToggleGroup.Item`.
4. Give `Select.Item` and `Combobox.Item` children, render the toast queue inside `Toast.Viewport`, and add layouts to `Menu.Group` and `ContextMenu.Group`.
5. Run each surface and restyle what went flat. Overlays, menu item highlights and switch tracks are the usual casualties.
6. Add `transition` wherever you want motion back.

## 0.6.0 → 0.6.1

A rename only — no runtime behavior, exports or APIs changed.

- Add the `react-` prefix to every import: `@lattice-ui/<name>` → `@lattice-ui/react-<name>`.
- `@lattice-ui/core` → `@lattice-ui/react-runtime`.
- Update `package.json` dependency entries before upgrading. Leaving both the old and new name listed makes two copies resolve side by side, which npm rejects.
- `npx lattice-ui init` rewrites the old names for you.
- If you pin CLI registry keys, `core` is now `runtime`. Other component keys are unchanged.

## 0.5.x → 0.6.0

- `Toast.Root`'s `transition` now takes a `PresenceMotionConfig` (`initial`/`reveal`/`exit`) instead of a `ResponseMotionConfig`.

## 0.4.x → 0.5.0

- Import focus and motion helpers from `@lattice-ui/focus` and `@lattice-ui/motion` rather than `@lattice-ui/core`.

## Related

- [Releases](https://docs.astra-void.xyz/lattice-ui/reference/releases.md)
- [Package stability](https://docs.astra-void.xyz/lattice-ui/getting-started/package-stability.md)
- [Styling with recipes](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-recipes.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
