Sidebar — Hover-to-Expand with Pin, and Dashboard-Wide Render Performance
Overview
Two related problems are addressed together because they share the same root cause.
Problem 1 — Sidebar behavior: The sidebar currently toggles expanded/collapsed on click via a chevron button at the bottom. The new behavior is hover-to-expand by default, with a pin chevron that locks the sidebar open permanently (shrinking the content viewport) until unpinned.
Problem 2 — Render performance: sidebarExpanded state lives in TournamentShell, which is the parent of every page in the tournament dashboard. Every hover-in and hover-out triggers a React re-render of the entire page subtree — including every row in the volunteers table, events table, sheets mapping table, and any other data-heavy view. This is the primary cause of jank during sidebar transitions.
The fix restructures sidebar state so that data views are never re-rendered by sidebar interactions, and introduces row virtualization and component memoization as a shared standard for all data-heavy views.
Motivation
The sidebar is in normal document flow — it pushes the content area when it expands (52px → 192px). Currently, sidebarExpanded is owned by TournamentShell and passed down to <Sidebar>, which means the layout's sidebarWidth variable changes on every hover event. Because <main> is a child of TournamentShell, every page's content re-renders on every sidebar state change. On pages with large data sets (volunteers, events, sheets) this causes visible lag during the hover animation.
The sidebar animation itself is a CSS width transition — React has no business re-rendering data rows because of it.
Part 1 — Sidebar Behavior
New interaction model
| State |
Description |
| Collapsed (default) |
52px wide, icons only. Content viewport is full width. |
| Hover-expanded |
Sidebar widens to 192px on mouseenter, collapses back on mouseleave. The sidebar overlays the content area — content viewport does not shrink. |
| Pinned |
User clicks the chevron. Sidebar stays at 192px regardless of hover. Content viewport shrinks to accommodate. Persisted across page loads (localStorage). |
| Unpinned |
User clicks chevron again. Returns to hover-expanded behavior. |
Key distinction: overlay vs. push
- Hover-expanded: sidebar uses
position: fixed (or absolute with a high z-index), floating over the content. The content area does not reflow.
- Pinned: sidebar is in normal document flow, pushing content to the right. This is when the viewport actually shrinks.
This means the content area only ever reflowing when the user deliberately pins or unpins — not on every hover.
Chevron placement and appearance
The chevron lives at the bottom of the sidebar, same position as the current toggle button. Its visual state:
- Unpinned: chevron points right (→), dimmed. Tooltip: "Pin sidebar".
- Pinned: chevron points left (←), accent color. Tooltip: "Unpin sidebar".
The chevron is always visible when the sidebar is expanded (hovered or pinned). It is hidden when the sidebar is collapsed (only icons showing).
Implementation changes
Sidebar.tsx
Remove the expanded prop and onToggle callback. The sidebar manages its own expanded state internally:
// Two independent pieces of state
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(() => {
// Read from localStorage on mount
if (typeof window === "undefined") return false;
return localStorage.getItem("sidebar-pinned") === "true";
});
const isExpanded = pinned || hovered;
function handleMouseEnter() { setHovered(true); }
function handleMouseLeave() { setHovered(false); }
function handlePin() {
setPinned((v) => {
const next = !v;
localStorage.setItem("sidebar-pinned", String(next));
return next;
});
}
When pinned changes, notify the layout so it can adjust the content margin:
interface SidebarProps {
tournamentId: string | number;
onPinnedChange: (pinned: boolean) => void;
}
onPinnedChange is the only callback the sidebar exposes. Hover state is fully internal.
layout.tsx (TournamentShell)
Replace sidebarExpanded with sidebarPinned:
const [sidebarPinned, setSidebarPinned] = useState(false);
const sidebarWidth = sidebarPinned ? EXPANDED_W : COLLAPSED_W;
sidebarWidth now only changes when the user pins or unpins — not on hover. The content area reflows only then.
Sidebar positioning when unpinned (hover-expanded)
When the sidebar is not pinned, it should expand over the content without pushing it. Change the sidebar's CSS position to fixed when !pinned, and sticky (current behavior) when pinned:
position: pinned ? "sticky" : "fixed",
When position: fixed, the content area must add a left margin equal to COLLAPSED_W (52px) so it isn't obscured by the collapsed sidebar rail. When position: sticky, the content area's left margin comes from the sidebar being in flow.
Part 2 — Dashboard-Wide Render Performance
With Part 1 in place, the only remaining trigger for content re-renders is the sidebarPinned state change, which happens on deliberate user action — not animation frames. But data views still re-render unnecessarily when other unrelated state changes higher in the tree (e.g. tournament data loading, topbar state). The following patterns should be applied to every data-heavy view.
Affected views
| Page |
Components |
| Volunteers |
VolunteerCardGrid, volunteer table |
| Events |
EventTable, EventCardGrid, EventTimeline, TimeBlocksTable |
| Sheets |
Mapping table, sheet config list |
1. Memoize all data view components
Wrap every data-heavy component in React.memo. Ensure props passed to them are referentially stable:
// Stable data reference — new array reference won't be created on parent re-render
const volunteers = useMemo(() => rawVolunteers, [rawVolunteers]);
// Stable callbacks
const handleRowClick = useCallback((id: string) => { ... }, []);
const VolunteerTable = React.memo(({ data, onRowClick }: Props) => {
// ...
});
2. Isolate panel/modal state from data components
Any state that controls a panel, modal, or popover must not live in a component that is also an ancestor of a data view. Use a dedicated context or co-locate the state with the panel itself, not with the page root.
// Bad — panelOpen re-renders the table
function VolunteersPage() {
const [panelOpen, setPanelOpen] = useState(false);
return (
<>
<VolunteerTable data={volunteers} /> {/* re-renders on panelOpen change */}
<VolunteerPanel open={panelOpen} />
</>
);
}
// Good — table never sees panelOpen
const VolunteerPanelContext = createContext(...);
function VolunteersPage() {
return (
<VolunteerPanelProvider>
<VolunteerTable data={volunteers} /> {/* isolated */}
<VolunteerPanel /> {/* reads from context */}
</VolunteerPanelProvider>
);
}
3. Virtualize all tables
Install the TanStack suite:
npm install @tanstack/react-table @tanstack/react-virtual
Apply useVirtualizer to every table with more than ~30 expected rows. The virtualizer renders only the rows currently visible in the scroll container, plus an overscan buffer:
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 48, // row height in px — adjust per table
overscan: 10,
});
When the content area resizes (e.g. sidebar pinned/unpinned), the virtualizer recalculates its visible window automatically via ResizeObserver. No React re-render of row data is triggered by the resize.
Apply to:
- Volunteer table
EventTable
TimeBlocksTable
- Sheet config list (lower priority — fewer rows, but apply for consistency)
- Sheet mapping table (column mapping rows in the edit view)
4. Use useTransition for non-urgent updates
React 19 is already in use. Wrap sidebar pin/unpin and tab switches in startTransition so they don't block urgent renders (typing in search, scrolling):
const [isPending, startTransition] = useTransition();
function handlePin() {
startTransition(() => setSidebarPinned((v) => !v));
}
Implementation Order
- Sidebar state refactor — self-contained in
Sidebar.tsx and layout.tsx. No page-level changes.
- Panel state isolation — per page, starting with Volunteers (largest data set).
React.memo + stable refs — apply across all data components.
- Virtualization — apply table by table, starting with Volunteers and Events.
useTransition — apply to sidebar pin and tab switches.
Out of Scope
- Card grid virtualization (windowing 2D grids is more complex; defer until card counts make it necessary)
- Server-side pagination (the current in-memory approach is fine at current data volumes; this is a backend concern if it becomes necessary)
- Any changes to the Topbar
Sidebar — Hover-to-Expand with Pin, and Dashboard-Wide Render Performance
Overview
Two related problems are addressed together because they share the same root cause.
Problem 1 — Sidebar behavior: The sidebar currently toggles expanded/collapsed on click via a chevron button at the bottom. The new behavior is hover-to-expand by default, with a pin chevron that locks the sidebar open permanently (shrinking the content viewport) until unpinned.
Problem 2 — Render performance:
sidebarExpandedstate lives inTournamentShell, which is the parent of every page in the tournament dashboard. Every hover-in and hover-out triggers a React re-render of the entire page subtree — including every row in the volunteers table, events table, sheets mapping table, and any other data-heavy view. This is the primary cause of jank during sidebar transitions.The fix restructures sidebar state so that data views are never re-rendered by sidebar interactions, and introduces row virtualization and component memoization as a shared standard for all data-heavy views.
Motivation
The sidebar is in normal document flow — it pushes the content area when it expands (52px → 192px). Currently,
sidebarExpandedis owned byTournamentShelland passed down to<Sidebar>, which means the layout'ssidebarWidthvariable changes on every hover event. Because<main>is a child ofTournamentShell, every page's content re-renders on every sidebar state change. On pages with large data sets (volunteers, events, sheets) this causes visible lag during the hover animation.The sidebar animation itself is a CSS
widthtransition — React has no business re-rendering data rows because of it.Part 1 — Sidebar Behavior
New interaction model
mouseenter, collapses back onmouseleave. The sidebar overlays the content area — content viewport does not shrink.localStorage).Key distinction: overlay vs. push
position: fixed(or absolute with a high z-index), floating over the content. The content area does not reflow.This means the content area only ever reflowing when the user deliberately pins or unpins — not on every hover.
Chevron placement and appearance
The chevron lives at the bottom of the sidebar, same position as the current toggle button. Its visual state:
The chevron is always visible when the sidebar is expanded (hovered or pinned). It is hidden when the sidebar is collapsed (only icons showing).
Implementation changes
Sidebar.tsxRemove the
expandedprop andonTogglecallback. The sidebar manages its own expanded state internally:When
pinnedchanges, notify the layout so it can adjust the content margin:onPinnedChangeis the only callback the sidebar exposes. Hover state is fully internal.layout.tsx(TournamentShell)Replace
sidebarExpandedwithsidebarPinned:sidebarWidthnow only changes when the user pins or unpins — not on hover. The content area reflows only then.Sidebar positioning when unpinned (hover-expanded)
When the sidebar is not pinned, it should expand over the content without pushing it. Change the sidebar's CSS
positiontofixedwhen!pinned, andsticky(current behavior) whenpinned:When
position: fixed, the content area must add a left margin equal toCOLLAPSED_W(52px) so it isn't obscured by the collapsed sidebar rail. Whenposition: sticky, the content area's left margin comes from the sidebar being in flow.Part 2 — Dashboard-Wide Render Performance
With Part 1 in place, the only remaining trigger for content re-renders is the
sidebarPinnedstate change, which happens on deliberate user action — not animation frames. But data views still re-render unnecessarily when other unrelated state changes higher in the tree (e.g. tournament data loading, topbar state). The following patterns should be applied to every data-heavy view.Affected views
VolunteerCardGrid, volunteer tableEventTable,EventCardGrid,EventTimeline,TimeBlocksTable1. Memoize all data view components
Wrap every data-heavy component in
React.memo. Ensure props passed to them are referentially stable:2. Isolate panel/modal state from data components
Any state that controls a panel, modal, or popover must not live in a component that is also an ancestor of a data view. Use a dedicated context or co-locate the state with the panel itself, not with the page root.
3. Virtualize all tables
Install the TanStack suite:
Apply
useVirtualizerto every table with more than ~30 expected rows. The virtualizer renders only the rows currently visible in the scroll container, plus an overscan buffer:When the content area resizes (e.g. sidebar pinned/unpinned), the virtualizer recalculates its visible window automatically via
ResizeObserver. No React re-render of row data is triggered by the resize.Apply to:
EventTableTimeBlocksTable4. Use
useTransitionfor non-urgent updatesReact 19 is already in use. Wrap sidebar pin/unpin and tab switches in
startTransitionso they don't block urgent renders (typing in search, scrolling):Implementation Order
Sidebar.tsxandlayout.tsx. No page-level changes.React.memo+ stable refs — apply across all data components.useTransition— apply to sidebar pin and tab switches.Out of Scope