From b751025339d9f3b44d024808295d2939020ce0dc Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 4 Jun 2026 10:56:11 -0500
Subject: [PATCH] Mobile UI/UX improvements (#23402)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* increase camera group icon size on mobile
add an animated slider when there is not enough space for all defined camera groups
* change desktop and mobile edit camera groups icon to pencil and add desktop tooltip
* apply safe area insets to mobile layout in PWA mode using viewport-fit=cover
* adaptively size bottom bar nav targets to 48px when they fit, else compact
icon size now targets the standardized 48×48px mobile touch target (Material Design 3 / Android 48dp bottom-nav minimum)
---
web/index.html | 2 +-
web/public/locales/en/components/camera.json | 3 +
web/src/App.tsx | 4 +-
.../components/filter/CameraGroupSelector.tsx | 258 ++++++++++++++++--
web/src/components/icons/LiveIcons.tsx | 6 +-
web/src/components/menu/GeneralSettings.tsx | 11 +-
web/src/components/mobile/MobilePage.tsx | 7 +-
web/src/components/navigation/Bottombar.tsx | 89 +++++-
web/src/components/navigation/NavItem.tsx | 5 +-
web/src/components/ui/drawer.tsx | 7 +-
web/src/views/live/LiveDashboardView.tsx | 31 ++-
11 files changed, 363 insertions(+), 60 deletions(-)
diff --git a/web/index.html b/web/index.html
index be6b302f5c..bb019d4263 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,7 +3,7 @@
-
+
Frigate
diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx
index d4f01481f3..8f32846fc1 100644
--- a/web/src/components/filter/CameraGroupSelector.tsx
+++ b/web/src/components/filter/CameraGroupSelector.tsx
@@ -8,7 +8,17 @@ import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { HiDotsHorizontal } from "react-icons/hi";
+import { IoClose } from "react-icons/io5";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu";
import {
@@ -56,7 +66,6 @@ import { z } from "zod";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
-import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
@@ -145,7 +154,142 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false);
- const Scroller = isMobile ? ScrollArea : "div";
+ // mobile overflow reveal - the group strip sits left of the logo and is
+ // clipped (not scrollable) when there are too many groups, so render only
+ // the buttons that fully fit and surface a kebab next to the last visible
+ // one that expands a panel revealing all of them
+
+ const [expanded, setExpanded] = useState(false);
+ // null => all buttons fit, render them all with no kebab; a number => only
+ // that many fit alongside the kebab
+ const [visibleCount, setVisibleCount] = useState(null);
+ const wrapperRef = useRef(null);
+ const measureRef = useRef(null);
+
+ useLayoutEffect(() => {
+ if (isDesktop) {
+ return;
+ }
+
+ const wrapper = wrapperRef.current;
+ const measure = measureRef.current;
+
+ if (!wrapper || !measure) {
+ return;
+ }
+
+ const gap = 8; // gap-2 between buttons in the strip
+ const wrapperGap = 4; // gap-1 between the strip and the kebab
+
+ const compute = () => {
+ const buttons = Array.from(measure.children) as HTMLElement[];
+
+ if (buttons.length === 0) {
+ return;
+ }
+
+ // the trailing child of the measurement row is a kebab clone
+ const kebab = buttons[buttons.length - 1];
+ const groupButtons = buttons.slice(0, -1);
+ const available = wrapper.clientWidth;
+ const fullWidth =
+ groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
+ Math.max(groupButtons.length - 1, 0) * gap;
+
+ if (fullWidth <= available) {
+ setVisibleCount(null);
+ return;
+ }
+
+ const budget = available - kebab.offsetWidth - wrapperGap;
+ let used = 0;
+ let count = 0;
+
+ for (const el of groupButtons) {
+ const next = (count === 0 ? 0 : gap) + el.offsetWidth;
+
+ if (used + next <= budget) {
+ used += next;
+ count += 1;
+ } else {
+ break;
+ }
+ }
+
+ setVisibleCount(Math.max(count, 1));
+ };
+
+ compute();
+
+ const observer = new ResizeObserver(compute);
+ observer.observe(wrapper);
+
+ return () => observer.disconnect();
+ }, [groups, isAdmin]);
+
+ const groupButtons = (afterSelect?: () => void) => {
+ const buttons = [
+ ,
+ ...groups.map(([name, config]) => (
+
+ )),
+ ];
+
+ if (isAdmin) {
+ buttons.push(
+ ,
+ );
+ }
+
+ return buttons;
+ };
return (
<>
@@ -158,12 +302,11 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
deleteGroup={deleteGroup}
isAdmin={isAdmin}
/>
-
+ {isDesktop ? (
+ {visibleCount != null && (
)}
- {isMobile && }
+
+ {/* invisible row used only to measure natural button widths so we
+ can render exactly the buttons that fully fit */}
+