Floating Widget Add-In Pattern
This document describes the architectural pattern used for floating widget add-ins in TendSocial, based on the reference implementation of FeedbackWidget.
Overview
Floating widgets are fixed-position UI components that persist across navigation and maintain state independently of the page content. Examples include:
- FeedbackWidget - User feedback/bug reporting (bottom-right)
- ThemeEditor - Live theme color editing (bottom-left)
Key Characteristics
- Fixed Position - Uses CSS
fixedpositioning relative to viewport, not page content - State Persistence - State survives route changes because it's stored in a Context Provider wrapping the entire app
- Navigation Independence - Opening/closing state is maintained when user navigates between pages
- Z-Index Management - Uses high z-index (z-50 or higher) to float above page content
Architecture Components
1. Context Provider (*Context.tsx)
The Context holds all persistent state and actions. It lives at the top level of the component tree (wrapping <Routes>).
Location: apps/frontend/src/contexts/
Required State:
interface WidgetContextType {
// UI State
isOpen: boolean;
isMinimized: boolean;
// Form/Data State (persists across navigation)
// ... widget-specific fields
// Actions
open: () => void;
close: () => void;
minimize: () => void;
maximize: () => void;
reset: () => void; // Clears form data
// State Setters
// ... widget-specific setters
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Key Implementation Rules:
close()does NOT reset form state - allows user to navigate and returnreset()explicitly clears all form state - called on submit or cancel- All actions wrapped in
useCallbackfor performance
2. Provider Wrapping (App.tsx)
The Provider MUST wrap the entire <Routes> component, outside of any route-specific components.
// App.tsx
return (
<WidgetProvider> {/* ← Wraps EVERYTHING */}
<Routes>
{/* All routes here */}
</Routes>
</WidgetProvider>
);2
3
4
5
6
7
8
3. Widget Component (*Widget.tsx)
The visual component that renders the floating UI.
Location: apps/frontend/src/components/common/
Required Features:
- Uses
useWidgetContext()hook to access global state - CSS
fixedpositioning, NOTabsolute - Three render states:
- Collapsed/Closed - Just button/icon
- Minimized - Compact indicator showing work-in-progress
- Open - Full widget panel
Positioning:
// Closed state - just the trigger button
<div className="fixed bottom-4 right-4 z-50">
<button onClick={open}>...</button>
</div>
// Open state - full panel
<div className="fixed bottom-4 right-4 z-[100] w-[400px]">
{/* Widget content */}
</div>2
3
4
5
6
7
8
9
4. Widget Placement (App.tsx)
The widget component is rendered INSIDE the main layout div but OUTSIDE of the <Routes> component's nested routes:
<div className="flex h-screen bg-theme-base overflow-hidden">
<Sidebar ... />
<main className="...">
<Routes>
{/* Page routes */}
</Routes>
</main>
{/* Floating widgets - outside main, inside flex container */}
<FeedbackWidget enabled={isEnabled} />
</div>2
3
4
5
6
7
8
9
10
11
CRITICAL: Widget is NOT inside <main> - it floats over everything.
Implementation Checklist
When creating a new floating widget:
[ ] Create Context file:
contexts/{Name}Context.tsx- [ ] Define state interface
- [ ] Create Provider component with useState/useCallback
- [ ] Export
use{Name}hook
[ ] Create Widget component:
components/common/{Name}Widget.tsx- [ ] Import and use context hook
- [ ] Implement closed/minimized/open render states
- [ ] Use fixed positioning with appropriate z-index
- [ ] Handle open/close/minimize/maximize transitions
[ ] Update App.tsx:
- [ ] Import Provider
- [ ] Wrap Routes with Provider
- [ ] Import Widget
- [ ] Render Widget in main layout div (outside Routes, outside main)
[ ] (Optional) Create Admin Config:
components/platform/{Name}Config.tsx- [ ] Allow enabling/disabling via feature flag
Reference Implementations
FeedbackWidget (Right Side)
| File | Purpose |
|---|---|
| [FeedbackContext.tsx](file:///Users/marc/Desktop/git%20repos/tendsocial/apps/frontend/src/contexts/FeedbackContext.tsx) | Global state for feedback form |
| [FeedbackWidget.tsx](file:///Users/marc/Desktop/git%20repos/tendsocial/apps/frontend/src/components/common/FeedbackWidget.tsx) | The floating widget UI |
| [IssueForm.tsx](file:///Users/marc/Desktop/git%20repos/tendsocial/apps/frontend/src/components/common/IssueForm.tsx) | The form inside the widget |
| [FeedbackWidgetConfig.tsx](file:///Users/marc/Desktop/git%20repos/tendsocial/apps/frontend/src/components/platform/FeedbackWidgetConfig.tsx) | Admin toggle |
Position: fixed bottom-4 right-4
Behavior:
- Click icon → opens panel
- X button → closes but preserves form content
- Minimize button → collapses to small indicator
- Navigate to different page → widget state preserved
- Cancel button → clears form and closes
- Submit → sends data, resets form, closes
ThemeEditor (Left Side)
| File | Purpose |
|---|---|
| [ThemeEditorContext.tsx](file:///Users/marc/Desktop/git%20repos/tendsocial/apps/frontend/src/contexts/ThemeEditorContext.tsx) | Global state for colors, mode, open/minimized state |
| [ThemeEditorWidget.tsx](file:///Users/marc/Desktop/git%20repos/tendsocial/apps/frontend/src/components/common/ThemeEditorWidget.tsx) | The floating widget UI |
| [@tendsocial/theme](file:///Users/marc/Desktop/git%20repos/tendsocial/packages/theme/src/config.ts) | Theme colors config + generateCSSVariables() utility |
Position: fixed bottom-4 with dynamic left offset based on sidebar
Sidebar Offset Handling
Unlike right-side widgets, left-side widgets must account for the sidebar width:
// Sidebar widths (must match Sidebar component)
const SIDEBAR_EXPANDED_WIDTH = 256; // lg:w-64
const SIDEBAR_COLLAPSED_WIDTH = 80; // lg:w-20
const WIDGET_MARGIN = 16;
// Calculate left offset
const sidebarWidth = sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
const leftOffset = sidebarWidth + WIDGET_MARGIN;
// Apply via inline style
<div style={{ left: `${leftOffset}px` }} className="fixed bottom-4 z-50">2
3
4
5
6
7
8
9
10
11
The sidebarCollapsed prop must be passed from App.tsx:
<ThemeEditorWidget
enabled={isAuthenticated && themeEditorEnabled}
sidebarCollapsed={isSidebarCollapsed}
/>2
3
4
Context State
interface ThemeEditorContextType {
isOpen: boolean;
isMinimized: boolean;
activeMode: 'dark' | 'light';
colors: { dark: ThemeColors; light: ThemeColors };
// Actions
open: () => void;
close: () => void;
minimize: () => void;
maximize: () => void;
reset: () => void; // Reverts to default theme
exportConfig: () => void; // Copies config to clipboard
// Setters
setActiveMode: (mode: 'dark' | 'light') => void;
updateColor: (mode, key, value) => void;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Live CSS Variable Updates
The context applies color changes immediately via CSS custom properties:
useEffect(() => {
const cssVars = generateCSSVariables(colors[activeMode]);
Object.entries(cssVars).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
});
}, [colors, activeMode]);2
3
4
5
6
Behavior:
- Click icon → opens color picker panel
- Edit colors → live preview via CSS variables
- X button → closes but preserves color edits
- Minimize → collapses to "Continue Editing" indicator
- Reset → reverts to default theme colors
- Export Config → copies JSON to clipboard for committing
Anti-Patterns to Avoid
- ❌ Using
absolutepositioning - Will position relative to parent, not viewport - ❌ Rendering inside
<Routes>or page components - State lost on navigation - ❌ Using useState in the Widget for form data - State lost on re-render
- ❌ Calling hooks after conditional returns - Violates React Rules of Hooks
- ❌ Placing widget inside
<main>- May be clipped or affected by overflow - ❌ Calling reset() on close() - User loses work when navigating
- ❌ Hardcoding left offset for sidebar - Breaks when sidebar collapses (use dynamic calculation)