|
| 1 | +# Implementing a Resizable Global Side Panel in React |
| 2 | + |
| 3 | +Sometimes, a modal is just too intrusive. You want to show detailed context—like a complex rating system or metadata—without forcing the user to lose their place on the page or blocking the entire UI with a backdrop that demands immediate attention. Enter the **Sliding Side Panel**. |
| 4 | + |
| 5 | +In this post, I'll walk through how I implemented a global side panel system for **Fezcodex**, allowing any component in the app to trigger a content-rich overlay that slides in smoothly from the right. Even better? I made it **resizable**, so users can drag to expand the view if they need more space. |
| 6 | + |
| 7 | +## The Goal |
| 8 | + |
| 9 | +The immediate need was simple: I wanted to explain my G4-inspired 5-star rating system on the Logs page. A simple tooltip wasn't enough, and a full modal felt heavy-handed. I wanted a panel that felt like an extension of the UI, sliding in to offer "more details" on demand. |
| 10 | + |
| 11 | +## The Architecture |
| 12 | + |
| 13 | +To make this truly reusable, I avoided prop drilling by using the **Context API**. |
| 14 | + |
| 15 | +### Why Context? Avoiding Prop Drilling |
| 16 | + |
| 17 | +Without a global context, implementing a feature like this would require **prop drilling**. This is a common pattern (or anti-pattern) in React where you pass data or functions down through multiple layers of components just to get them to where they are needed. |
| 18 | + |
| 19 | +Imagine we managed the side panel state in `App.js`. We would have to pass the `openSidePanel` function like this: |
| 20 | + |
| 21 | +`App` → `Layout` → `MainContent` → `LogsPage` → `LogCard` → `InfoButton` |
| 22 | + |
| 23 | +Every intermediate component would need to accept and pass along a prop it doesn't even use. This makes refactoring a nightmare and clutters your component signatures. By using the **Context API**, we can bypass the middle layers entirely. Any component, no matter how deep in the tree, can simply reach out and grab the `openSidePanel` function directly. |
| 24 | + |
| 25 | +### 1. The Context (`SidePanelContext`) |
| 26 | + |
| 27 | +We need a way to tell the app: "Open the panel with *this* title, *this* content, and start at *this* width." |
| 28 | + |
| 29 | +```javascript |
| 30 | +// src/context/SidePanelContext.js |
| 31 | +import React, { createContext, useContext, useState } from 'react'; |
| 32 | + |
| 33 | +const SidePanelContext = createContext(); |
| 34 | + |
| 35 | +export const useSidePanel = () => useContext(SidePanelContext); |
| 36 | + |
| 37 | +export const SidePanelProvider = ({ children }) => { |
| 38 | + const [isOpen, setIsOpen] = useState(false); |
| 39 | + const [panelContent, setPanelContent] = useState(null); |
| 40 | + const [panelTitle, setPanelTitle] = useState(''); |
| 41 | + const [panelWidth, setPanelWidth] = useState(450); // Default width |
| 42 | + |
| 43 | + // openSidePanel now accepts an optional initial width |
| 44 | + const openSidePanel = (title, content, width = 450) => { |
| 45 | + setPanelTitle(title); |
| 46 | + setPanelContent(content); |
| 47 | + setPanelWidth(width); |
| 48 | + setIsOpen(true); |
| 49 | + }; |
| 50 | + |
| 51 | + const closeSidePanel = () => setIsOpen(false); |
| 52 | + |
| 53 | + return ( |
| 54 | + <SidePanelContext.Provider |
| 55 | + value={{ |
| 56 | + isOpen, |
| 57 | + panelTitle, |
| 58 | + panelContent, |
| 59 | + panelWidth, |
| 60 | + setPanelWidth, |
| 61 | + openSidePanel, |
| 62 | + closeSidePanel |
| 63 | + }} |
| 64 | + > |
| 65 | + {children} |
| 66 | + </SidePanelContext.Provider> |
| 67 | + ); |
| 68 | +}; |
| 69 | +``` |
| 70 | + |
| 71 | +This allows any component to call `openSidePanel('My Title', <MyComponent />, 600)` to trigger the UI with a custom starting width. |
| 72 | + |
| 73 | +### 2. The Component (`SidePanel`) |
| 74 | + |
| 75 | +The visual component uses **Framer Motion** for silky smooth entrance and exit animations, and vanilla JS event listeners for the resize logic. |
| 76 | + |
| 77 | +```javascript |
| 78 | +// src/components/SidePanel.js |
| 79 | +import { motion, AnimatePresence } from 'framer-motion'; |
| 80 | +import { useState, useEffect } from 'react'; |
| 81 | +import { useSidePanel } from '../context/SidePanelContext'; |
| 82 | + |
| 83 | +const SidePanel = () => { |
| 84 | + const { isOpen, closeSidePanel, panelTitle, panelContent, panelWidth, setPanelWidth } = useSidePanel(); |
| 85 | + const [isResizing, setIsResizing] = useState(false); |
| 86 | + |
| 87 | + // Resize Logic |
| 88 | + useEffect(() => { |
| 89 | + const handleMouseMove = (e) => { |
| 90 | + if (!isResizing) return; |
| 91 | + const newWidth = window.innerWidth - e.clientX; |
| 92 | + // Constrain width: min 300px, max 90% of screen |
| 93 | + if (newWidth > 300 && newWidth < window.innerWidth * 0.9) { |
| 94 | + setPanelWidth(newWidth); |
| 95 | + } |
| 96 | + }; |
| 97 | + |
| 98 | + const handleMouseUp = () => setIsResizing(false); |
| 99 | + |
| 100 | + if (isResizing) { |
| 101 | + window.addEventListener('mousemove', handleMouseMove); |
| 102 | + window.addEventListener('mouseup', handleMouseUp); |
| 103 | + document.body.style.cursor = 'ew-resize'; |
| 104 | + document.body.style.userSelect = 'none'; // Prevent text selection while dragging |
| 105 | + } |
| 106 | + |
| 107 | + return () => { |
| 108 | + window.removeEventListener('mousemove', handleMouseMove); |
| 109 | + window.removeEventListener('mouseup', handleMouseUp); |
| 110 | + document.body.style.cursor = 'default'; |
| 111 | + document.body.style.userSelect = 'auto'; |
| 112 | + }; |
| 113 | + }, [isResizing, setPanelWidth]); |
| 114 | + |
| 115 | + return ( |
| 116 | + <AnimatePresence> |
| 117 | + {isOpen && ( |
| 118 | + <> |
| 119 | + <motion.div onClick={closeSidePanel} className="fixed inset-0 bg-black/50 z-[60]" /> |
| 120 | + |
| 121 | + <motion.div |
| 122 | + initial={{ x: '100%' }} |
| 123 | + animate={{ x: 0 }} |
| 124 | + exit={{ x: '100%' }} |
| 125 | + transition={{ type: 'spring', damping: 25, stiffness: 200 }} |
| 126 | + style={{ width: panelWidth }} // Dynamic width |
| 127 | + className="fixed top-0 right-0 h-full bg-gray-900 border-l border-gray-700 z-[70] flex flex-col" |
| 128 | + > |
| 129 | + {/* Resize Handle */} |
| 130 | + <div |
| 131 | + onMouseDown={(e) => { setIsResizing(true); e.preventDefault(); }} |
| 132 | + className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize hover:bg-primary-500/50 transition-colors z-50" |
| 133 | + /> |
| 134 | + |
| 135 | + {/* Header & Content */} |
| 136 | + </motion.div> |
| 137 | + </> |
| 138 | + )} |
| 139 | + </AnimatePresence> |
| 140 | + ); |
| 141 | +}; |
| 142 | +``` |
| 143 | + |
| 144 | +### 3. Integration |
| 145 | + |
| 146 | +I wrapped the entire application in the `SidePanelProvider` in `App.js` and placed the `<SidePanel />` component in `Layout.js`. This ensures the panel is always available and renders on top of everything else. |
| 147 | + |
| 148 | +## Inspiration |
| 149 | + |
| 150 | +The first use case for this panel was to detail the **Rating System** for my logs. I wanted to pay homage to the classic **X-Play (G4TV)** scale, emphasizing that a **3 out of 5** is a solid, good score—not a failure. |
| 151 | + |
| 152 | +The side panel proved perfect for this: users can check the rating criteria without leaving the logs list, keeping their browsing flow uninterrupted. |
| 153 | + |
| 154 | +## Conclusion |
| 155 | + |
| 156 | +Global UI elements controlled via Context are a powerful pattern in React. By adding a simple resize handle and managing width in the global state, we've transformed a static overlay into a flexible, user-friendly tool that adapts to the user's needs. |
0 commit comments