Skip to content

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

  1. Fixed Position - Uses CSS fixed positioning relative to viewport, not page content
  2. State Persistence - State survives route changes because it's stored in a Context Provider wrapping the entire app
  3. Navigation Independence - Opening/closing state is maintained when user navigates between pages
  4. 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:

typescript
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
}

Key Implementation Rules:

  • close() does NOT reset form state - allows user to navigate and return
  • reset() explicitly clears all form state - called on submit or cancel
  • All actions wrapped in useCallback for performance

2. Provider Wrapping (App.tsx)

The Provider MUST wrap the entire <Routes> component, outside of any route-specific components.

tsx
// App.tsx
return (
    <WidgetProvider>      {/* ← Wraps EVERYTHING */}
        <Routes>
            {/* All routes here */}
        </Routes>
    </WidgetProvider>
);

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 fixed positioning, NOT absolute
  • Three render states:
    1. Collapsed/Closed - Just button/icon
    2. Minimized - Compact indicator showing work-in-progress
    3. Open - Full widget panel

Positioning:

tsx
// 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>

4. Widget Placement (App.tsx)

The widget component is rendered INSIDE the main layout div but OUTSIDE of the <Routes> component's nested routes:

tsx
<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>

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)

FilePurpose
[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:

  1. Click icon → opens panel
  2. X button → closes but preserves form content
  3. Minimize button → collapses to small indicator
  4. Navigate to different page → widget state preserved
  5. Cancel button → clears form and closes
  6. Submit → sends data, resets form, closes

ThemeEditor (Left Side)

FilePurpose
[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

Unlike right-side widgets, left-side widgets must account for the sidebar width:

typescript
// 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">

The sidebarCollapsed prop must be passed from App.tsx:

tsx
<ThemeEditorWidget 
    enabled={isAuthenticated && themeEditorEnabled} 
    sidebarCollapsed={isSidebarCollapsed} 
/>

Context State

typescript
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;
}

Live CSS Variable Updates

The context applies color changes immediately via CSS custom properties:

typescript
useEffect(() => {
    const cssVars = generateCSSVariables(colors[activeMode]);
    Object.entries(cssVars).forEach(([key, value]) => {
        document.documentElement.style.setProperty(key, value);
    });
}, [colors, activeMode]);

Behavior:

  1. Click icon → opens color picker panel
  2. Edit colors → live preview via CSS variables
  3. X button → closes but preserves color edits
  4. Minimize → collapses to "Continue Editing" indicator
  5. Reset → reverts to default theme colors
  6. Export Config → copies JSON to clipboard for committing

Anti-Patterns to Avoid

  1. ❌ Using absolute positioning - Will position relative to parent, not viewport
  2. ❌ Rendering inside <Routes> or page components - State lost on navigation
  3. ❌ Using useState in the Widget for form data - State lost on re-render
  4. ❌ Calling hooks after conditional returns - Violates React Rules of Hooks
  5. ❌ Placing widget inside <main> - May be clipped or affected by overflow
  6. ❌ Calling reset() on close() - User loses work when navigating
  7. ❌ Hardcoding left offset for sidebar - Breaks when sidebar collapses (use dynamic calculation)

TendSocial Documentation