Using Sidenotes in MDX

Sidenotes are a powerful way to add contextual information 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:

  • 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 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.

Final Architecture: Simplified React Components with DOM Manipulation

After several iterations, we simplified our architecture to:

  1. Sidenote.tsx - A minimal, stateless component that renders a placeholder
  2. SidenotesProvider.tsx - Handles desktop rendering in the sidebar
  3. SidenoteMobile.tsx - Manages mobile-specific interactions
  4. Shared Icon Component - Uses our global Icon component system for consistent styling

The architecture now separates desktop and mobile concerns cleanly while leveraging our existing component library.

Mobile UX Evolution: From Inline to Bottom Sheet

In our final iteration, we shifted from inline expanding sidenotes to a more modern bottom sheet approach:

// Bottom sheet modal approach in SidenotesContext.tsx
{isClient && isMobile && isMobileSidenoteOpen && activeMobileSidenote && (
  <div className="sidenote-mobile-drawer">
    <div className="sidenote-mobile-overlay" onClick={closeMobileSidenote} />
    <div className="sidenote-mobile-content">
      <div className="sidenote-close-button-container">
        <Button 
          variant="ghost" 
          size="sm" 
          onClick={closeMobileSidenote}
          aria-label="Close sidenote"
        >
          ×
        </Button>
      </div>
      {sidenotes.get(activeMobileSidenote)?.content}
    </div>
  </div>
)}

This approach provides several benefits:

  • Preserves the reading flow without pushing content down
  • Provides more space for sidenote content
  • Follows familiar mobile UI patterns (similar to iOS/Android bottom sheets)
  • Creates clearer separation between main content and supplementary information

Technical Implementation

Our improved sidenote system required several key simplifications:

Simplified Component Structure

We reduced the Sidenote component to its simplest form:

export default function Sidenote({ children, id }: SidenoteProps) {
  // Generate a stable, unique ID
  const uniqueId = id || useId().replace(/:/g, "-");
 
  // Render a simple placeholder 
  return (
    <span data-sidenote data-ref-id={uniqueId} className="sidenote">
      <div className="sidenote-content">{children}</div>
    </span>
  );
}

This simplified approach:

  • Removes unnecessary state management
  • Uses React's built-in useId() hook for stable IDs
  • Provides data attributes for DOM manipulation

Leveraging Existing Component Library

Instead of crafting custom elements, we leveraged our existing component library:

// Using our shared Icon component for the sidenote indicator
const iconRoot = createRoot(anchor);
iconRoot.render(createElement(Icon, { 
  name: "message-square", 
  size: 16,
  className: "sidenote-icon-svg"
}));
 
// Using our Button component for the close button in the mobile sheet
<Button 
  variant="ghost" 
  size="sm" 
  onClick={closeMobileSidenote}
  aria-label="Close sidenote"
>
  ×
</Button>

This improved:

  • Visual consistency across the app
  • Maintainability when component designs change
  • Accessibility through standardized ARIA attributes
  • Code reuse instead of duplicate styling

Mobile Experience Improvements

For mobile, we made significant improvements to how sidenotes are displayed:

/* Mobile sidenote drawer styling */
.sidenote-mobile-drawer {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  flex-direction: column;
  pointer-events: none;
}
 
.sidenote-mobile-content {
  position: relative;
  margin-top: auto;
  background-color: var(--mono1);
  border-top-left-radius: 1rem;
  border-top-right-radius: 1rem;
  padding: 1.5rem;
  padding-top: 2.5rem;
  max-height: 70vh;
  overflow-y: auto;
  transform: translateY(100%);
  animation: slide-up 0.3s ease forwards;
  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
  pointer-events: auto;
}

This approach:

  • Prevents layout shifts that would disrupt reading
  • Creates a more natural reading flow
  • Follows familiar mobile UI patterns
  • Provides adequate space for rich sidenote content
  • Maintains context through a semi-transparent overlay

Simplified CSS Architecture

We also streamlined our CSS:

/* Simplified positioning container for the button */
.sidenote-close-button-container {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
}
 
/* Animations for the bottom sheet */
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
 
@keyframes slide-up {
  from { transform: translateY(100%); }
  to { transform: translateY(0); }
}

This simplification:

  • Reduced CSS complexity by removing custom button styles
  • Created cleaner separation of concerns
  • Improved animation performance with targeted transforms
  • Eliminated duplicated styling logic by leveraging existing components

Handling Multiple Sidenotes

A key challenge was managing multiple sidenotes when they appear close together in text. We implemented a document-order positioning system:

// First, calculate all ideal positions (aligned with their anchors)
const sidenotePositions = anchorsRef.current.map((anchor) => {
  // Get anchor and calculate ideal position...
  return {
    refId,
    node: sidenote,
    idealTop, // Where it would appear aligned with anchor
    height,
    actualTop: idealTop, // Initially set to ideal position
    isLastClicked: refId === lastClickedRef.current
  };
}).filter(Boolean);
 
// Sort by document order
sidenotePositions.sort((a, b) => a.idealTop - b.idealTop);
 
// Fix the last clicked at its ideal position
lastClicked.actualTop = lastClicked.idealTop;
 
// Adjust sidenotes before the last clicked (push up if needed)
for (let i = lastClickedIndex - 1; i >= 0; i--) {
  // Push up to avoid overlap...
}
 
// Adjust sidenotes after the last clicked (push down if needed)
for (let i = lastClickedIndex + 1; i < sidenotePositions.length; i++) {
  // Push down to avoid overlap...
}

This approach:

  • Preserves the natural document order of sidenotes
  • Ensures the clicked sidenote stays aligned with its anchor text
  • Smoothly pushes other sidenotes up or down to avoid overlaps
  • Scales to any number of sidenotes, no matter how densely placed

Animation Refinements

In our final iteration, we added smooth animations when sidenotes reposition:

.sidebar-right .sidenote {
  /* Other styles... */
  transition: transform 0.2s ease, top 0.3s ease-out;
}

These subtle animations:

  • Make repositioning feel natural and intentional
  • Provide visual cues about sidenote relationships
  • Help users track which sidenote corresponds to which anchor
  • Maintain smooth performance with hardware acceleration

Performance Optimizations

As a final polish, we implemented several performance optimizations:

// Optimize scroll handler with requestAnimationFrame
const handleScroll = () => {
  if (!scrollTicking.current) {
    scrollTicking.current = true;
    window.requestAnimationFrame(() => {
      updateSidenotesPositions();
      scrollTicking.current = false;
    });
  }
};
 
// Debounce resize handler
const handleResize = () => {
  if (resizeTimeout.current) {
    clearTimeout(resizeTimeout.current);
  }
  
  resizeTimeout.current = window.setTimeout(() => {
    updateSidenotesPositions();
  }, 100);
};

These optimizations include:

  • Throttling with requestAnimationFrame to prevent excessive calculations during scrolling
  • Debouncing resize events to avoid performance hits during window resizing
  • CSS will-change property to inform the browser about animated properties
  • Cleanup of event listeners to prevent memory leaks
  • Reduced DOM manipulation by reusing elements instead of recreating them

We also optimized our CSS by removing redundant properties and leveraging inheritance, resulting in cleaner, more maintainable styles.

Key Lessons Learned

From building our improved sidenote system, we learned several valuable lessons:

  1. Leverage existing components - Using our Button and Icon components simplified the implementation
  2. Minimal state management - Keeping components stateless where possible improved reliability
  3. Consider user reading patterns - Bottom sheets preserve the reading flow better than inline expansion
  4. Unique IDs matter - Stable, unique IDs (via React's useId()) prevent sync issues between views
  5. Separation of concerns - Having dedicated desktop/mobile handlers simplified the logic
  6. Follow platform patterns - Using familiar mobile UI patterns improves usability

Visual Design Evolution

The visual design evolution focused on integration with our design system:

/* 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 in the same sentence to provide different types of information 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:

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

  1. Keyboard support - Full keyboard navigation and activation
  2. ARIA states - Proper expanded/collapsed state announcements
  3. Focus management - Clear focus indicators and proper tab order
  4. Motion reduction - Support for users who prefer reduced motion
  5. 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!

Correspondence

Get notified when I write. Feel free to reply directly.