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:

css
.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:

css
.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:

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

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

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

  1. Specialized algorithms that handle complex positioning logic
  2. Edge case handling for viewport boundaries and scrolling containers
  3. Performance optimizations built into the library
  4. Accessibility improvements with proper ARIA attribute management
  5. Middleware architecture for composable positioning behaviors

Handling Complex Positioning Scenarios

Floating UI helped us solve challenging positioning scenarios:

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

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

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

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

  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()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: Loading

css
/* 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

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

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

tsx
// 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
css
@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.