Using Sidenotes in MDX
Sidenotes are a powerful way to add contextual information Loading to your content without disrupting the main flow of text. They're perfect for adding supplementary information, references, or interesting asides.
What are Sidenotes?
Sidenotes are annotations that appear in the margins of your content. Unlike footnotes that require readers to jump to the bottom of the page, sidenotes display information right next to the relevant text, making it easier for readers to consume supplementary information without losing their place.
When to Use Sidenotes
Sidenotes are particularly useful for: Loading
- Definitions of technical terms
- Citations and references
- Asides and tangential thoughts
- Code examples related to a concept
- Additional context that enhances but isn't critical to the main content
Implementation Journey
Creating a sidenote system that works across devices required several iterations Loading and careful design decisions. Here's what we learned in the process:
Initial Approach: CSS-Only Position
Our first attempt used pure CSS positioning to place sidenotes in the margin:
.sidenote {
position: absolute;
right: -200px;
width: 180px;
/* Other styling */
}
This approach quickly proved problematic:
- Sidenotes overlapped on smaller screens
- They didn't reflow with the content properly
- Accessibility issues for screen readers
Second Approach: Two-Column Grid Layout
We then tried a grid-based layout:
.article-container {
display: grid;
grid-template-columns: 1fr 200px;
gap: 2rem;
}
This was better for desktop but still failed on mobile, where we needed a completely different approach.
Third Iteration: Manual Position Calculation
Next, we implemented a React context-based system with manual positioning:
// SidenotesContext.tsx with manual positioning
const updatePositions = useCallback(() => {
const newPositions = new Map();
for (const [id, sidenote] of sidenotes.entries()) {
const refRect = sidenote.referenceElement.getBoundingClientRect();
const sidebarRect = sidebarRef.current.getBoundingClientRect();
// Calculate position relative to sidebar
const top = refRect.top - sidebarRect.top;
// Store position
newPositions.set(id, { id, top, left: 0 });
}
setPositions(newPositions);
}, [sidenotes]);
This approach gave us more control but introduced new issues:
- Complex overlap prevention logic
- Inconsistent positioning across browser reflows
- Positioning bugs during scrolling and resizing
- Difficult to handle edge cases (viewport boundaries, scrolling containers)
Latest Approach: Floating UI Integration
Our latest iteration leverages the Floating UI library to handle positioning correctly:
import { computePosition, shift, offset, flip } from "@floating-ui/dom";
// Using Floating UI for proper positioning
const updatePositions = useCallback(async () => {
const newPositions = new Map();
for (const [id, sidenote] of Array.from(sidenotes.entries())) {
const referenceElement = sidenote.referenceElement;
if (!referenceElement) continue;
try {
// Virtual element representing the sidenote in the sidebar
const virtualElement = {
getBoundingClientRect() {
return {
width: sidebarRect.width,
height: 80, // Estimated height
x: sidebarRect.x,
y: 0, // To be computed
top: 0,
right: sidebarRect.right,
bottom: 80,
left: sidebarRect.left,
};
}
};
// Use Floating UI to compute optimal position
const position = await computePosition(referenceElement, virtualElement, {
placement: 'right-start',
middleware: [
offset({ mainAxis: 10, crossAxis: 10 }),
flip(),
shift()
]
});
// Use the computed position
newPositions.set(id, {
id,
top: position.y,
left: 0
});
} catch (error) {
console.error("Error computing position:", error);
}
}
setPositions(newPositions);
}, [sidenotes, sidebarExists]);
This approach dramatically improved our sidenote positioning by leveraging Floating UI's specialized algorithms designed for precisely this kind of UI challenge.
Technical Implementation
Our floating UI integration provides several key benefits: Loading
Why Floating UI?
We chose Floating UI over manual positioning for several important reasons:
// Floating UI provides specialized middleware for different positioning needs
const middleware = [
// Keep 10px distance from the reference element
offset({ mainAxis: 10, crossAxis: 10 }),
// Flip to the other side if there's not enough space
flip(),
// Shift along the axis if needed to stay in viewport
shift({ padding: 8 })
];
These benefits include:
- Specialized algorithms that handle complex positioning logic
- Edge case handling for viewport boundaries and scrolling containers
- Performance optimizations built into the library
- Accessibility improvements with proper ARIA attribute management
- Middleware architecture for composable positioning behaviors
Handling Complex Positioning Scenarios
Floating UI helped us solve challenging positioning scenarios:
// Example of handling viewport constraints
const handleViewportConstraints = () => {
// Floating UI's shift middleware automatically handles this
// by shifting the element to stay within the viewport
return shift({
padding: 10, // Stay 10px from viewport edges
crossAxis: true, // Allow shifting on both axes
limiter: limitShift() // Limit shifting to avoid jitter
});
};
This provides:
- Better handling of viewport boundaries
- Proper positioning when scrolling
- Stability during browser reflows
- Graceful fallbacks when ideal positioning isn't possible
Integrating with React Server Components
We integrated Floating UI with our server component architecture:
// Server component that loads the client behavior
export default function Sidenote({ children, id }) {
return (
<Suspense fallback={<SidenoteFallback id={id}>{children}</SidenoteFallback>}>
<SidenoteClient id={id}>{children}</SidenoteClient>
</Suspense>
);
}
// Client component that uses Floating UI
const SidenoteClient = dynamic(() => import("./SidenoteClient"), {
ssr: false,
});
This approach:
- Keeps the server component simple and stateless
- Loads Floating UI only on the client side
- Uses React's suspense for a smoother loading experience
- Separates positioning logic from the component interface
Improved Mobile Experience
Our mobile experience now uses a bottom sheet approach, also positioned using Floating UI principles:
// Mobile-specific dialog positioning
{isMobileSidenoteOpen && (
<SidenoteDialog
isOpen={isMobileSidenoteOpen}
onOpenChange={setIsMobileSidenoteOpen}
content={currentMobileId ? sidenotes.get(currentMobileId)?.content : null}
/>
)}
This creates:
- A consistent positioning system across desktop and mobile
- Better handling of mobile viewport constraints
- Smooth animations during dialog open/close transitions
- Proper focus management for accessibility
Middleware for Special Behaviors
One of the most powerful aspects of Floating UI is its middleware system:
// Custom middleware for positioning behaviors
const customMiddleware = {
name: 'preventOverlap',
fn: ({ x, y, elements, rects, middlewareData }) => {
// Check for overlaps with other elements
const overlaps = checkForOverlaps(
rects.floating,
previousPositions
);
// Adjust position to prevent overlaps
if (overlaps) {
return {
y: y + overlaps.offsetY
};
}
return {};
}
};
This enables us to:
- Create custom positioning behaviors
- Compose complex positioning logic from simple building blocks
- Apply consistent positioning rules across components
- Isolate positioning concerns from the rest of the application
By using Floating UI for our sidenote positioning, we've created a more robust, maintainable system that handles complex edge cases automatically and provides a smoother user experience.
Key Lessons Learned
From building our improved sidenote system, we learned several valuable lessons: Loading
- Leverage existing components - Using our Button and Icon components simplified the implementation
- Minimal state management - Keeping components stateless where possible improved reliability
- Consider user reading patterns - Bottom sheets preserve the reading flow better than inline expansion
- Unique IDs matter - Stable, unique IDs (via React's
useId()
useId()
) prevent sync issues between views - Separation of concerns - Having dedicated desktop/mobile handlers simplified the logic
- Follow platform patterns - Using familiar mobile UI patterns improves usability
Visual Design Evolution
The visual design evolution focused on integration with our design system: Loading
/* Bottom sheet animations */
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Desktop highlighting for active notes */
.sidebar-right .sidenote.highlighted {
background-color: var(--mono4);
transform: translateX(-8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 2;
border-left: 2px solid var(--accent-blue);
}
This approach creates:
- Smooth transitions between states
- Visual feedback when interacting with sidenotes
- Consistent design language with the rest of the site
- Familiar mobile patterns that users understand intuitively
Usage Examples
Using sidenotes in your MDX content remains straightforward:
Basic Sidenote
<Sidenote>
This is a simple sidenote with basic text.
</Sidenote>
Multiple Sidenotes in One Sentence
You can use multiple sidenotes Loading in the same sentence to provide different types of information Loading without disrupting the flow of your content.
When using multiple sidenotes close together, our system intelligently positions them to avoid overlap in the sidebar. The last clicked sidenote will always maintain its position aligned with its anchor, while others adjust to make room.
Sidenote with Rich Content
<Sidenote>
## Small Heading
- List item one
- List item two
*Italics* and **bold** are supported!
</Sidenote>
Accessibility Considerations
Accessibility was a key focus in our implementation: Loading
// Proper ARIA attributes for sidenote anchors
anchor.setAttribute("role", "button");
anchor.setAttribute("tabindex", "0");
anchor.setAttribute("aria-label", "Open note");
anchor.setAttribute("aria-expanded", "false");
anchor.setAttribute("aria-controls", "sidenote-mobile-content");
// Leveraging the Button component for built-in accessibility
<Button
variant="ghost"
size="sm"
onClick={closeMobileSidenote}
aria-label="Close sidenote"
>
×
</Button>
Our improved implementation includes:
- Keyboard support - Full keyboard navigation and activation
- ARIA states - Proper expanded/collapsed state announcements
- Focus management - Clear focus indicators and proper tab order
- Motion reduction - Support for users who prefer reduced motion
- Component reuse - Leveraging our accessible Button component
@media (prefers-reduced-motion) {
.sidenote-mobile-content {
animation: none;
transform: translateY(0);
}
.sidenote-mobile-overlay {
animation: none;
}
}
Conclusion
Our sidenote system has evolved through multiple iterations to balance aesthetics, functionality, and technical simplicity. The final iteration with the bottom sheet mobile pattern and reusable components has created a more polished, maintainable implementation that enhances content without disrupting the reading experience.
By leveraging our existing Button and Icon components and separating desktop/mobile concerns, we've created a system that follows platform conventions while maintaining a consistent visual language. The bottom sheet approach for mobile provides a more natural reading experience by preserving content flow.
The journey taught us valuable lessons about responsive design, component reuse, and the importance of considering both desktop and mobile reading patterns. Each iteration improved both the code quality and the reader experience.
Try incorporating sidenotes in your next article to create a richer, more nuanced presentation of your ideas!