feat(ui): make overlays and controls move from where they come from

Menus, popovers and combobox lists now slide in from their trigger on
whichever side they open (data-side aware, RTL-correct), and submenus
open in 120ms and close in 84ms. The combobox list exits at 0.7x like
every other popup, and tooltips scale from their trigger.

Toasts keep travelling in the swipe direction when dismissed, ease back
on a cancelled swipe, and a replaced toast drops out before the new one
has finished rising.

The tabs indicator uses the movement curve and now also draws the line
variant's underline, so it slides instead of teleporting. Hotkey mode
switches in the resume and letter builders land instantly. Radio dots
and checkbox ticks grow in instead of popping or only fading.
This commit is contained in:
Amruth Pillai
2026-09-29 12:56:19 +02:00
parent 7827ff11bf
commit 607c561792
13 changed files with 53 additions and 21 deletions
+11
View File
@@ -19,3 +19,14 @@ export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
/** dnd-kit timing for things settling into place (sortable shuffles, drop animations): on-screen movement. */
export const DRAG_SETTLE = { duration: D2 * 1000, easing: `cubic-bezier(${EASE_MOVE.join(", ")})` };
/**
* Hotkey mode switches land at once: the editor's mode-switch indicator skips its slide for this one change
* (keyboard shortcuts are used too often to animate). The flag stays two frames, until the new position has painted.
*/
export function switchModeInstantly(change: () => void) {
const list = document.querySelector<HTMLElement>('[data-slot="tabs-list"][data-mode-switch]');
list?.setAttribute("data-instant", "");
change();
requestAnimationFrame(() => requestAnimationFrame(() => list?.removeAttribute("data-instant")));
}
@@ -68,7 +68,7 @@ function ModeTabs() {
const labels: Record<EditorMode, string> = { write: t`Write`, design: t`Design`, check: t`Check` };
return (
<TabsList aria-label={t`Editor mode`} className="h-9">
<TabsList aria-label={t`Editor mode`} className="h-9" data-mode-switch="">
{(Object.keys(MODE_ICONS) as EditorMode[]).map((mode) => (
<TabsTrigger key={mode} value={mode} className="px-4">
<Icon name={MODE_ICONS[mode]} size={18} />
@@ -5,6 +5,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { isEditableElementFocused, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { switchModeInstantly } from "@/libs/motion";
/**
* The editor's keyboard map: 1/2/3 switch modes outside fields, ⌘Z and ⇧⌘Z undo and redo outside fields
@@ -20,9 +21,9 @@ export function useEditorHotkeys(setMode: (mode: EditorMode) => void) {
const { select, setShareTab, setAssistantOpen } = useEditorStore.getState();
const isOnline = () => useResumeStore.getState().saveStatus !== "offline";
useHotkey("1", () => setMode("write"));
useHotkey("2", () => setMode("design"));
useHotkey("3", () => setMode("check"));
useHotkey("1", () => switchModeInstantly(() => setMode("write")));
useHotkey("2", () => switchModeInstantly(() => setMode("design")));
useHotkey("3", () => switchModeInstantly(() => setMode("check")));
useHotkey("Mod+Z", () => {
if (!isEditableElementFocused()) undo();
@@ -57,7 +57,7 @@ export function LetterBar({ layout, pinnable }: LetterBarProps) {
{layout === "mobile" ? (
<span />
) : (
<TabsList aria-label={t`Editor mode`} className="h-9">
<TabsList aria-label={t`Editor mode`} className="h-9" data-mode-switch="">
<TabsTrigger value="write" className="px-4">
<Icon name="edit" size={18} />
<Trans>Write</Trans>
@@ -27,6 +27,7 @@ import {
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { useIsLandscape } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { switchModeInstantly } from "@/libs/motion";
import { LetterDesignPanel } from "./design-panel";
import { LetterBar, useDownloadLetter } from "./letter-bar";
import { LetterPage } from "./letter-page";
@@ -228,8 +229,8 @@ function LetterHotkeys({ onModeChange }: { onModeChange: (mode: LetterMode) => v
const download = useDownloadLetter();
const setShareTab = useEditorStore((state) => state.setShareTab);
useHotkey("1", () => onModeChange("write"));
useHotkey("2", () => onModeChange("design"));
useHotkey("1", () => switchModeInstantly(() => onModeChange("write")));
useHotkey("2", () => switchModeInstantly(() => onModeChange("design")));
useHotkey("Mod+P", () => void download.run());
useHotkey("Mod+Shift+E", () => setShareTab("download"));
useHotkey("Mod+J", () => useEditorStore.getState().setAssistantOpen(!useEditorStore.getState().assistantOpen, true));
+1 -1
View File
@@ -14,7 +14,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-opacity duration-quick data-ending-style:opacity-0 data-starting-style:opacity-0"
className="flex items-center justify-center text-current transition-[opacity,scale] duration-quick ease-enter data-ending-style:scale-[0.8] data-starting-style:scale-[0.8] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d1)*0.7)]"
>
<Icon name={props.indeterminate ? "remove" : "check"} size={16} />
</CheckboxPrimitive.Indicator>
+3 -2
View File
@@ -7,7 +7,7 @@ import {
InputGroupButton,
InputGroupInput,
} from "@reactive-resume/ui/components/input-group";
import { menuSeparatorClassName } from "@reactive-resume/ui/components/menu-styles";
import { menuSeparatorClassName, popupSlideClassName } from "@reactive-resume/ui/components/menu-styles";
import { cn } from "@reactive-resume/utils/style";
const ComboboxRoot = ComboboxPrimitive.Root;
@@ -102,7 +102,8 @@ function ComboboxContent({
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-fit min-w-[calc(var(--anchor-width)+--spacing(7))] max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl bg-raised text-ink shadow-e2 transition-[opacity,scale,translate] duration-standard ease-enter data-[chips=true]:min-w-(--anchor-width) data-ending-style:-translate-y-1 data-starting-style:-translate-y-1 data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:bg-bg *:data-[slot=input-group]:shadow-none",
"group/combobox-content relative max-h-(--available-height) w-fit min-w-[calc(var(--anchor-width)+--spacing(7))] max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl bg-raised text-ink shadow-e2 transition-[opacity,scale,translate] duration-standard ease-enter data-[chips=true]:min-w-(--anchor-width) data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d2)*0.7)] *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:bg-bg *:data-[slot=input-group]:shadow-none",
popupSlideClassName,
className,
)}
{...props}
+9 -2
View File
@@ -1,9 +1,16 @@
/**
* Popups start 4px back toward their trigger and settle outward, on whichever side they open. Base UI sets
* `data-side` to top | bottom | left | right, or inline-start | inline-end when the side was given logically.
*/
export const popupSlideClassName =
"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=bottom]:data-ending-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1 data-[side=top]:data-ending-style:translate-y-1 data-[side=right]:data-starting-style:-translate-x-1 data-[side=right]:data-ending-style:-translate-x-1 data-[side=left]:data-starting-style:translate-x-1 data-[side=left]:data-ending-style:translate-x-1 ltr:data-[side=inline-end]:data-starting-style:-translate-x-1 ltr:data-[side=inline-end]:data-ending-style:-translate-x-1 rtl:data-[side=inline-end]:data-starting-style:translate-x-1 rtl:data-[side=inline-end]:data-ending-style:translate-x-1 ltr:data-[side=inline-start]:data-starting-style:translate-x-1 ltr:data-[side=inline-start]:data-ending-style:translate-x-1 rtl:data-[side=inline-start]:data-starting-style:-translate-x-1 rtl:data-[side=inline-start]:data-ending-style:-translate-x-1";
/**
* Shared look for dropdown menus, context menus and combobox lists: 12px radius, e2, 4px padding,
* 36px items with an 8px radius. Destructive items go last, after a separator, in danger text.
* Submenus (`data-nested`) open in 120ms and close in 84ms: they open and close on every hover pass.
*/
export const menuPopupClassName =
"relative z-50 max-h-(--available-height) min-w-[220px] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-xl bg-raised p-1 text-ink shadow-e2 outline-none transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:-translate-y-1 data-starting-style:-translate-y-1 data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-closed:overflow-hidden data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)]";
export const menuPopupClassName = `relative z-50 max-h-(--available-height) min-w-[220px] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-xl bg-raised p-1 text-ink shadow-e2 outline-none transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-closed:overflow-hidden data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)] data-nested:duration-quick data-nested:data-ending-style:duration-[calc(var(--d1)*0.7)] ${popupSlideClassName}`;
export const menuItemClassName =
"relative flex min-h-9 cursor-default select-none items-center gap-2.5 rounded-md px-2.5 text-ink text-sm outline-hidden data-disabled:pointer-events-none data-highlighted:bg-hover data-inset:ps-9 data-disabled:text-ink-3 data-[variant=destructive]:text-danger-text [&_[data-slot=icon]]:text-ink-2 data-[variant=destructive]:[&_[data-slot=icon]]:text-danger-text [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0";
+3 -1
View File
@@ -1,5 +1,6 @@
import type * as React from "react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { popupSlideClassName } from "@reactive-resume/ui/components/menu-styles";
import { cn } from "@reactive-resume/utils/style";
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
@@ -31,7 +32,8 @@ function PopoverContent({
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-xl bg-raised p-3 text-ink text-sm shadow-e2 outline-hidden transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:-translate-y-1 data-starting-style:-translate-y-1 data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)]",
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-xl bg-raised p-3 text-ink text-sm shadow-e2 outline-hidden transition-[opacity,scale,translate] duration-standard ease-enter data-ending-style:scale-[0.98] data-starting-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d2)*0.7)]",
popupSlideClassName,
className,
)}
{...props}
+5 -2
View File
@@ -6,7 +6,7 @@ function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return <RadioGroupPrimitive data-slot="radio-group" className={cn("grid gap-2", className)} {...props} />;
}
/** An 18px circle with a 1.5px border; the chosen option shows an 8px accent dot. */
/** An 18px circle with a 1.5px border; the chosen option's 8px accent dot grows in from half size. */
function RadioGroupItem({ className, ...props }: Radio.Root.Props) {
return (
<Radio.Root
@@ -17,7 +17,10 @@ function RadioGroupItem({ className, ...props }: Radio.Root.Props) {
)}
{...props}
>
<Radio.Indicator data-slot="radio-group-indicator" className="size-2 rounded-full bg-accent" />
<Radio.Indicator
data-slot="radio-group-indicator"
className="size-2 rounded-full bg-accent transition-[opacity,scale] duration-quick ease-enter data-ending-style:scale-50 data-starting-style:scale-50 data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d1)*0.7)]"
/>
</Radio.Root>
);
}
+10 -4
View File
@@ -16,7 +16,7 @@ function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive
/**
* `default` is the segmented control used for modes (a sunken track with a raised selected
* segment). `line` is the underline tab strip used for filters and sheet sections.
* segment). `line` is the underline tab strip used for filters and sheet sections; its underline slides like the pill.
*/
const tabsListVariants = cva(
"group/tabs-list relative inline-flex w-fit items-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
@@ -46,18 +46,24 @@ function TabsList({
className={cn(tabsListVariants({ variant }), className)}
{...props}
>
{variant === "default" && <TabsIndicator />}
<TabsIndicator />
{children}
</TabsPrimitive.List>
);
}
/**
* Slides to the active tab: the raised pill on `default`, a 2px ink underline on `line`. Movement uses the
* on-screen curve; a list with `data-instant` (keyboard mode switches) jumps instead.
*/
function TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
return (
<TabsPrimitive.Indicator
data-slot="tabs-indicator"
className={cn(
"absolute top-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) translate-y-(--active-tab-top) rounded-sm bg-raised shadow-e1 transition-[translate,width,height] duration-standard ease-enter",
"absolute left-0 w-(--active-tab-width) translate-x-(--active-tab-left) transition-[translate,width,height] duration-standard ease-in-out-strong group-data-instant/tabs-list:transition-none",
"group-data-[variant=default]/tabs-list:top-0 group-data-[variant=default]/tabs-list:h-(--active-tab-height) group-data-[variant=default]/tabs-list:translate-y-(--active-tab-top) group-data-[variant=default]/tabs-list:rounded-sm group-data-[variant=default]/tabs-list:bg-raised group-data-[variant=default]/tabs-list:shadow-e1",
"group-data-[variant=line]/tabs-list:bottom-0 group-data-[variant=line]/tabs-list:h-0.5 group-data-[variant=line]/tabs-list:bg-ink",
className,
)}
{...props}
@@ -72,7 +78,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
className={cn(
"touch-target relative inline-flex min-w-fit items-center justify-center gap-1.5 whitespace-nowrap font-medium transition-colors duration-quick hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-disabled:pointer-events-none aria-disabled:text-ink-3 data-active:text-ink group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=default]/tabs-list:h-full group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:rounded-sm group-data-[variant=default]/tabs-list:px-3 group-data-[variant=default]/tabs-list:text-[13px]",
"group-data-[variant=line]/tabs-list:h-full group-data-[variant=line]/tabs-list:text-sm group-data-[variant=line]/tabs-list:data-active:shadow-[inset_0_-2px_0_var(--ink)]",
"group-data-[variant=line]/tabs-list:h-full group-data-[variant=line]/tabs-list:text-sm",
className,
)}
{...props}
+1 -1
View File
@@ -35,7 +35,7 @@ function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
<ToastPrimitive.Root
data-slot="toast"
className={cn(
"group/toast pointer-events-auto flex w-fit max-w-[min(560px,100%)] select-none items-center rounded-lg bg-ink text-bg shadow-e3 outline-none transition-[opacity,translate] duration-emphasized ease-enter [grid-area:1/1] focus-visible:outline-accent data-ending-style:translate-y-2.5 data-starting-style:translate-y-2.5 data-ending-style:opacity-0 data-limited:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d3)*0.7)]",
"group/toast pointer-events-auto flex w-fit max-w-[min(560px,100%)] select-none items-center rounded-lg bg-ink text-bg shadow-e3 outline-none transition-[opacity,translate,transform] duration-emphasized ease-enter [grid-area:1/1] focus-visible:outline-accent data-[swipe-direction=right]:data-ending-style:translate-x-[calc(var(--toast-swipe-movement-x)+100%)] data-[swipe-direction=down]:data-ending-style:translate-y-[calc(var(--toast-swipe-movement-y)+100%)] data-[swipe-direction=right]:data-ending-style:translate-y-0 data-ending-style:translate-y-2.5 data-limited:translate-y-2.5 data-starting-style:translate-y-2.5 data-ending-style:opacity-0 data-limited:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--d3)*0.7)] data-limited:duration-[calc(var(--d3)*0.7)]",
className,
)}
{...props}
+1 -1
View File
@@ -35,7 +35,7 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-ink px-2.5 py-1.5 font-medium text-bg text-xs shadow-e2 transition-opacity duration-quick ease-enter has-data-[slot=kbd]:pe-1.5 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none",
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-ink px-2.5 py-1.5 font-medium text-bg text-xs shadow-e2 transition-[opacity,scale] duration-quick ease-enter has-data-[slot=kbd]:pe-1.5 data-ending-style:scale-[0.97] data-starting-style:scale-[0.97] data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none data-ending-style:duration-[calc(var(--d1)*0.7)]",
className,
)}
{...props}