feat(web): mark the active phone tab with a bar that springs between tabs

The phone tab bars (resume builder, letter builder, dashboard) drop the
pill behind the active icon for a short accent bar on the tab's top
edge. Switching tabs springs it across with a little overshoot
(spring 0.4s, bounce 0.3); reduced motion moves it at once. Dashboard
tab taps swap the page without the cross-fade, like a native tab bar,
so the bar's movement isn't hidden behind the page transition.
This commit is contained in:
Amruth Pillai
2026-09-29 14:44:16 +02:00
parent 25131b3a82
commit 9acf289b11
5 changed files with 37 additions and 21 deletions
@@ -0,0 +1,18 @@
import { m } from "motion/react";
import { TAB_SPRING } from "@/libs/motion";
/**
* The phone tab bar's marker: a short accent bar on the active tab's top edge. Render it inside the active tab
* (which must be `relative`); when another tab becomes active it springs across to it. Reduced motion moves it at
* once (see `followReducedMotion`).
*/
export function MobileTabIndicator() {
return (
<m.span
aria-hidden="true"
layoutId="mobile-tab-indicator"
transition={TAB_SPRING}
className="absolute inset-x-0 -top-px mx-auto h-0.5 w-8 rounded-full bg-accent"
/>
);
}
+3
View File
@@ -20,6 +20,9 @@ export const EXIT = 0.7;
/** --ease-out-strong. Landing page only (features/homepage/site-footer.tsx); app code uses EASE. */
export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
/** Springy, a little elastic: the mobile tab bar's marker hopping to the new tab (interruptible mid-flight). */
export const TAB_SPRING = { type: "spring", duration: 0.4, bounce: 0.3 } 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(", ")})` };
@@ -8,6 +8,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { MobileTabIndicator } from "@/components/layout/mobile-tab-indicator";
import {
AssistantColumn,
AssistantOverlay,
@@ -285,18 +286,12 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
if (tab !== "page") onModeChange(tab);
}}
className={cn(
"flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 text-[11px]",
"relative flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-[color,scale] duration-quick ease-enter active:scale-[0.97]",
active ? "font-semibold text-ink" : "text-ink-2",
)}
>
<span
className={cn(
"flex h-7 w-[52px] items-center justify-center rounded-full transition-colors duration-quick",
active && "bg-accent-soft text-accent-text",
)}
>
<Icon name={icon} size={24} filled={active} />
</span>
{active && <MobileTabIndicator />}
<Icon name={icon} size={24} filled={active} />
{labels[tab]}
</button>
);
@@ -10,6 +10,7 @@ import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { MobileTabIndicator } from "@/components/layout/mobile-tab-indicator";
import {
AssistantColumn,
AssistantOverlay,
@@ -213,18 +214,12 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
if (tab !== "page") onModeChange(tab);
}}
className={cn(
"flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 text-[11px]",
"relative flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-[color,scale] duration-quick ease-enter active:scale-[0.97]",
active ? "font-semibold text-ink" : "text-ink-2",
)}
>
<span
className={cn(
"flex h-7 w-[52px] items-center justify-center rounded-full transition-colors duration-quick",
active && "bg-accent-soft text-accent-text",
)}
>
<Icon name={icon} size={24} filled={active} />
</span>
{active && <MobileTabIndicator />}
<Icon name={icon} size={24} filled={active} />
{labels[tab]}
</button>
);
@@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/com
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { MobileTabIndicator } from "@/components/layout/mobile-tab-indicator";
import { useDialogStore } from "@/dialogs/store";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { useCommandPaletteStore } from "@/features/command-palette/store";
@@ -289,6 +290,7 @@ function MobileTabs() {
const tab = (icon: IconName, label: string, current: boolean) => (
<>
{current && <MobileTabIndicator />}
<Icon name={icon} size={24} filled={current} />
<span className={cn("text-[11px]", current ? "font-semibold text-ink" : "text-ink-2")}>{label}</span>
</>
@@ -302,14 +304,16 @@ function MobileTabs() {
<Link
to="/dashboard"
aria-current={isCurrent("/dashboard") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
viewTransition={false}
className="relative flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
{tab("description", t`Documents`, isCurrent("/dashboard"))}
</Link>
<Link
to="/dashboard/applications"
aria-current={isCurrent("/dashboard/applications") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
viewTransition={false}
className="relative flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
{tab("work", t`Applications`, isCurrent("/dashboard/applications"))}
</Link>
@@ -329,7 +333,8 @@ function MobileTabs() {
<Link
to="/dashboard/settings"
aria-current={isCurrent("/dashboard/settings") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
viewTransition={false}
className="relative flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
{tab("account_circle", t`Account`, isCurrent("/dashboard/settings"))}
</Link>