Skip to content

Commit b44ad47

Browse files
committed
new post.
1 parent 3ad1be9 commit b44ad47

File tree

7 files changed

+557
-238
lines changed

7 files changed

+557
-238
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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.

public/posts/posts.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
[
2+
{
3+
"slug": "implementing-a-sliding-side-panel",
4+
"title": "Implementing a Global Sliding Side Panel in React",
5+
"date": "2025-12-11",
6+
"updated": "2025-12-11",
7+
"description": "How I built a context-driven, global sliding side panel system for Fezcodex using React and Framer Motion.",
8+
"tags": ["react", "framer-motion", "context-api", "ui/ux", "frontend"],
9+
"category": "dev",
10+
"filename": "implementing-a-sliding-side-panel.txt",
11+
"authors": ["fezcode"],
12+
"image": "/images/defaults/visuals-2TS23o0-pUc-unsplash.jpg"
13+
},
214
{
315
"slug": "building-a-digital-rotary-phone",
416
"title": "Building a Digital Rotary Phone",

src/components/RatingSystemDetail.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import {StarIcon} from '@phosphor-icons/react';
2+
import { StarIcon } from '@phosphor-icons/react';
33

44
const RatingSystemDetail = () => {
55
const renderStars = (rating) => {
@@ -88,12 +88,14 @@ const RatingSystemDetail = () => {
8888
</div>
8989
</div>
9090
<div className="space-y-2 pt-4">
91-
<h3 className="text-white font-bold border-b border-gray-800 pb-2">Inspiration</h3>
91+
<h3 className="text-white font-bold border-b border-gray-800 pb-2">
92+
Inspiration
93+
</h3>
9294
<p className="text-sm text-gray-400">
93-
This rating system is heavily inspired by the classic <strong>X-Play (G4TV)</strong> scale.
94-
We believe in the sanctity of the 3-star review: a 3 is not bad, it
95-
is <strong>average</strong> or <strong>solid</strong>.
96-
Grade inflation has no place here.
95+
This rating system is heavily inspired by the classic{' '}
96+
<strong>X-Play (G4TV)</strong> scale. We believe in the sanctity of
97+
the 3-star review: a 3 is not bad, it is <strong>average</strong> or{' '}
98+
<strong>solid</strong>. Grade inflation has no place here.
9799
</p>
98100
</div>
99101
<div className="mt-8 pt-6 border-t border-gray-800 text-xs text-gray-600 italic">

src/components/SidePanel.js

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import React, {useEffect} from 'react';
2-
import {motion, AnimatePresence} from 'framer-motion';
3-
import {XIcon} from '@phosphor-icons/react';
4-
import {useSidePanel} from '../context/SidePanelContext';
1+
import React, { useEffect, useState } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { XIcon } from '@phosphor-icons/react';
4+
import { useSidePanel } from '../context/SidePanelContext';
55

66
const SidePanel = () => {
7-
const {isOpen, closeSidePanel, panelTitle, panelContent} = useSidePanel();
7+
const {
8+
isOpen,
9+
closeSidePanel,
10+
panelTitle,
11+
panelContent,
12+
panelWidth,
13+
setPanelWidth,
14+
} = useSidePanel();
15+
const [isResizing, setIsResizing] = useState(false);
816

917
// Close on escape key
1018
useEffect(() => {
@@ -17,17 +25,53 @@ const SidePanel = () => {
1725
return () => window.removeEventListener('keydown', handleKeyDown);
1826
}, [isOpen, closeSidePanel]);
1927

28+
// Handle resizing
29+
useEffect(() => {
30+
const handleMouseMove = (e) => {
31+
if (!isResizing) return;
32+
const newWidth = window.innerWidth - e.clientX;
33+
// Constrain width: min 300px, max 90% of screen
34+
if (newWidth > 300 && newWidth < window.innerWidth * 0.9) {
35+
setPanelWidth(newWidth);
36+
}
37+
};
38+
39+
const handleMouseUp = () => {
40+
setIsResizing(false);
41+
};
42+
43+
if (isResizing) {
44+
window.addEventListener('mousemove', handleMouseMove);
45+
window.addEventListener('mouseup', handleMouseUp);
46+
document.body.style.cursor = 'ew-resize';
47+
document.body.style.userSelect = 'none';
48+
} else {
49+
document.body.style.cursor = 'default';
50+
document.body.style.userSelect = 'auto';
51+
}
52+
53+
return () => {
54+
window.removeEventListener('mousemove', handleMouseMove);
55+
window.removeEventListener('mouseup', handleMouseUp);
56+
document.body.style.cursor = 'default';
57+
document.body.style.userSelect = 'auto';
58+
};
59+
}, [isResizing, setPanelWidth]);
60+
2061
const variants = {
21-
open: {x: 0, opacity: 1}, closed: {x: '100%', opacity: 1},
62+
open: { x: 0, opacity: 1 },
63+
closed: { x: '100%', opacity: 1 },
2264
};
2365

24-
return (<AnimatePresence>
25-
{isOpen && (<>
26-
{/* Overlay (optional, clicks close panel?) - Let's allow clicking outside to close */}
66+
return (
67+
<AnimatePresence>
68+
{isOpen && (
69+
<>
70+
{/* Overlay */}
2771
<motion.div
28-
initial={{opacity: 0}}
29-
animate={{opacity: 1}}
30-
exit={{opacity: 0}}
72+
initial={{ opacity: 0 }}
73+
animate={{ opacity: 1 }}
74+
exit={{ opacity: 0 }}
3175
onClick={closeSidePanel}
3276
className="fixed inset-0 bg-black/50 z-[60] backdrop-blur-sm"
3377
/>
@@ -37,19 +81,30 @@ const SidePanel = () => {
3781
animate="open"
3882
exit="closed"
3983
variants={variants}
40-
transition={{type: 'spring', damping: 25, stiffness: 200}}
41-
className="fixed top-0 right-0 h-full w-full max-w-md bg-gray-900 border-l border-gray-700 shadow-[-10px_0_30px_-10px_rgba(0,0,0,0.5)] z-[70] flex flex-col overflow-hidden"
84+
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
85+
style={{ width: panelWidth }}
86+
className="fixed top-0 right-0 h-full bg-gray-900 border-l border-gray-700 shadow-[-10px_0_30px_-10px_rgba(0,0,0,0.5)] z-[70] flex flex-col overflow-hidden"
4287
>
88+
{/* Resize Handle */}
89+
<div
90+
onMouseDown={(e) => {
91+
setIsResizing(true);
92+
e.preventDefault();
93+
}}
94+
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize hover:bg-primary-500/50 transition-colors z-50"
95+
title="Drag to resize"
96+
/>
97+
4398
{/* Header */}
4499
<div className="flex items-center justify-between p-6 border-b border-gray-800">
45-
<h2 className="text-xl font-mono font-bold text-gray-100 truncate">
100+
<h2 className="text-xl font-mono font-bold text-gray-100 truncate pr-4">
46101
{panelTitle}
47102
</h2>
48103
<button
49104
onClick={closeSidePanel}
50-
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-full transition-colors"
105+
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-full transition-colors flex-shrink-0"
51106
>
52-
<XIcon size={20}/>
107+
<XIcon size={20} />
53108
</button>
54109
</div>
55110

@@ -58,8 +113,10 @@ const SidePanel = () => {
58113
{panelContent}
59114
</div>
60115
</motion.div>
61-
</>)}
62-
</AnimatePresence>);
116+
</>
117+
)}
118+
</AnimatePresence>
119+
);
63120
};
64121

65122
export default SidePanel;

src/config/achievements.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,13 @@ export const ACHIEVEMENTS = [
796796
icon: <YinYangIcon size={32} weight="duotone" />,
797797
category: 'Secret',
798798
},
799+
{
800+
id: 'east_side',
801+
title: 'East Side',
802+
description: 'Opened the Right Side.',
803+
icon: <ColumnsIcon size={32} weight="duotone" />,
804+
category: 'Secret',
805+
},
799806
{
800807
id: 'clean_slate',
801808
title: 'Clean Slate',

src/context/SidePanelContext.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export const SidePanelProvider = ({ children }) => {
1010
const [isOpen, setIsOpen] = useState(false);
1111
const [panelContent, setPanelContent] = useState(null);
1212
const [panelTitle, setPanelTitle] = useState('');
13+
const [panelWidth, setPanelWidth] = useState(450);
1314

14-
const openSidePanel = (title, content) => {
15+
const openSidePanel = (title, content, width = 450) => {
1516
setPanelTitle(title);
1617
setPanelContent(content);
18+
setPanelWidth(width);
1719
setIsOpen(true);
1820
};
1921

@@ -28,6 +30,8 @@ export const SidePanelProvider = ({ children }) => {
2830
isOpen,
2931
panelTitle,
3032
panelContent,
33+
panelWidth,
34+
setPanelWidth,
3135
openSidePanel,
3236
closeSidePanel,
3337
}}

0 commit comments

Comments
 (0)