Skip to content

Commit e986cd3

Browse files
committed
Side note
1 parent 910e7b6 commit e986cd3

File tree

2 files changed

+303
-0
lines changed

2 files changed

+303
-0
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
---
2+
import { marked } from 'marked';
3+
4+
interface Props {
5+
id?: string;
6+
note: string;
7+
}
8+
9+
const { id, note } = Astro.props;
10+
const noteId = id || `sidenote-${Math.random().toString(36).substring(7)}`;
11+
12+
// Process markdown in the note content
13+
const processedNote = await marked.parseInline(note);
14+
---
15+
16+
<span class="sidenote-container">
17+
<input type="checkbox" id={noteId} class="sidenote-toggle" />
18+
<label for={noteId} class="sidenote-trigger"><slot /></label>
19+
<span class="sidenote-content">
20+
<label for={noteId} class="sidenote-close" aria-label="Close sidenote">−</label>
21+
<span class="sidenote-text" set:html={processedNote} />
22+
</span>
23+
</span>
24+
25+
<style>
26+
.sidenote-container {
27+
position: relative;
28+
}
29+
30+
.sidenote-toggle {
31+
display: none;
32+
}
33+
34+
.sidenote-trigger {
35+
cursor: pointer;
36+
text-decoration: underline;
37+
text-decoration-style: dotted;
38+
text-decoration-color: var(--color-ink);
39+
text-underline-offset: 2px;
40+
color: var(--color-ink);
41+
}
42+
43+
.sidenote-content {
44+
font-family: var(--font-prose);
45+
font-size: var(--text-sm);
46+
line-height: 1.5;
47+
color: var(--color-ink-light);
48+
}
49+
50+
.sidenote-close {
51+
display: none;
52+
}
53+
54+
.sidenote-text {
55+
display: contents;
56+
}
57+
58+
/* Default: inline toggle behavior for narrow screens */
59+
.sidenote-content {
60+
display: block;
61+
max-height: 0;
62+
opacity: 0;
63+
overflow: hidden;
64+
margin-top: 0;
65+
margin-bottom: 0;
66+
padding: 0 var(--spacing-md);
67+
background: var(--color-bg-code);
68+
border-radius: 4px;
69+
position: static;
70+
float: none;
71+
clear: none;
72+
width: auto;
73+
margin-left: 0;
74+
margin-right: 0;
75+
transition: max-height 0.3s ease-out, opacity 0.2s ease-in-out, margin 0.3s ease-out, padding 0.3s ease-out;
76+
}
77+
78+
.sidenote-toggle:checked ~ .sidenote-content {
79+
display: block;
80+
max-height: 50rem;
81+
opacity: 1;
82+
margin-top: var(--spacing-xs);
83+
margin-bottom: var(--spacing-xs);
84+
padding: var(--spacing-md);
85+
}
86+
87+
/* Wide screens with sufficient margin space: show in right margin */
88+
@media (min-width: 1400px) {
89+
.sidenote-trigger {
90+
cursor: pointer;
91+
position: relative;
92+
/* Keep inline to allow proper text wrapping */
93+
display: inline;
94+
/* Keep dotted underline for margin note mode */
95+
text-decoration: underline;
96+
text-decoration-style: dotted;
97+
/* Start with transparent background for smooth fade-in */
98+
background: transparent;
99+
padding: 0.125rem 0;
100+
border-radius: 2px;
101+
/* Ensure background properly wraps with text */
102+
box-decoration-break: clone;
103+
-webkit-box-decoration-break: clone;
104+
/* Smooth transition for the background */
105+
transition: background 0.2s ease;
106+
}
107+
108+
/* Hover state for trigger when applied via JS */
109+
.sidenote-trigger.hover-active {
110+
background: var(--color-highlight);
111+
color: var(--color-ink);
112+
}
113+
114+
.sidenote-content {
115+
float: right;
116+
clear: right;
117+
width: 16rem;
118+
margin-right: -18rem;
119+
background: var(--color-bg-code);
120+
color: var(--color-ink);
121+
border-radius: 4px;
122+
position: relative;
123+
overflow: hidden;
124+
/* Default to visible on wide screens */
125+
max-height: 50rem;
126+
opacity: 1;
127+
margin-top: 0.25rem;
128+
margin-bottom: 0.25rem;
129+
padding: var(--spacing-md);
130+
transition: max-height 0.3s ease-out, opacity 0.2s ease-in-out, background 0.2s, color 0.2s, margin 0.3s ease-out, padding 0.3s ease-out;
131+
}
132+
133+
.sidenote-content:hover,
134+
.sidenote-content.hover-active {
135+
background: var(--color-bg-hover);
136+
color: var(--color-ink);
137+
cursor: pointer;
138+
}
139+
140+
.sidenote-close {
141+
display: block;
142+
position: absolute;
143+
top: 0.125rem;
144+
right: 0.25rem;
145+
width: 1.25rem;
146+
height: 1.25rem;
147+
cursor: pointer;
148+
font-size: 1.125rem;
149+
line-height: 1;
150+
color: var(--color-ink-light);
151+
background: transparent;
152+
border: none;
153+
border-radius: 4px;
154+
text-align: center;
155+
transition: background 0.2s, color 0.2s;
156+
user-select: none;
157+
}
158+
159+
.sidenote-close:hover {
160+
background: var(--color-bg-hover);
161+
color: var(--color-ink);
162+
}
163+
164+
.sidenote-text {
165+
display: block;
166+
}
167+
168+
/* Toggle controls visibility in margin mode */
169+
.sidenote-toggle:not(:checked) ~ .sidenote-content {
170+
max-height: 0;
171+
opacity: 0;
172+
margin-top: 0;
173+
margin-bottom: 0;
174+
padding-top: 0;
175+
padding-bottom: 0;
176+
}
177+
178+
.sidenote-toggle:checked ~ .sidenote-content {
179+
max-height: 50rem;
180+
opacity: 1;
181+
margin-top: 0.25rem;
182+
margin-bottom: 0.25rem;
183+
padding: var(--spacing-md);
184+
}
185+
}
186+
</style>
187+
188+
<script>
189+
// Add bidirectional hover effect between sidenote content and trigger text
190+
// and prevent overlapping sidenotes in the margin
191+
document.addEventListener('DOMContentLoaded', () => {
192+
const sidenoteContainers = document.querySelectorAll('.sidenote-container');
193+
194+
// Initialize sidenotes as visible on wide screens
195+
sidenoteContainers.forEach(container => {
196+
const toggle = container.querySelector('.sidenote-toggle');
197+
if (toggle && window.innerWidth >= 1400) {
198+
toggle.checked = true;
199+
}
200+
});
201+
202+
sidenoteContainers.forEach(container => {
203+
const trigger = container.querySelector('.sidenote-trigger');
204+
const content = container.querySelector('.sidenote-content');
205+
const toggle = container.querySelector('.sidenote-toggle');
206+
207+
if (trigger && content) {
208+
// Hovering over sidenote highlights trigger
209+
content.addEventListener('mouseenter', () => {
210+
trigger.classList.add('hover-active');
211+
});
212+
213+
content.addEventListener('mouseleave', () => {
214+
trigger.classList.remove('hover-active');
215+
});
216+
217+
// Hovering over trigger highlights sidenote (only on wide screens)
218+
trigger.addEventListener('mouseenter', () => {
219+
if (window.innerWidth >= 1400) {
220+
content.classList.add('hover-active');
221+
}
222+
});
223+
224+
trigger.addEventListener('mouseleave', () => {
225+
if (window.innerWidth >= 1400) {
226+
content.classList.remove('hover-active');
227+
}
228+
});
229+
}
230+
231+
// Adjust positions when sidenote is toggled
232+
if (toggle) {
233+
toggle.addEventListener('change', () => {
234+
// Delay to allow max-height and opacity transition to complete (300ms)
235+
setTimeout(adjustSidenotePositions, 350);
236+
});
237+
}
238+
});
239+
240+
// Prevent overlapping sidenotes on wide screens
241+
function adjustSidenotePositions() {
242+
if (window.innerWidth < 1400) return; // Only apply on wide screens
243+
244+
const containers = Array.from(document.querySelectorAll('.sidenote-container'));
245+
246+
// Reset all previous adjustments
247+
containers.forEach(container => {
248+
const content = container.querySelector('.sidenote-content');
249+
if (content) {
250+
content.style.marginTop = '';
251+
}
252+
});
253+
254+
// Filter to only visible sidenotes (where toggle is checked)
255+
const visibleContainers = containers.filter(container => {
256+
const toggle = container.querySelector('.sidenote-toggle');
257+
return toggle && toggle.checked;
258+
});
259+
260+
// Get visible content elements
261+
const visibleContents = visibleContainers
262+
.map(container => container.querySelector('.sidenote-content'))
263+
.filter(Boolean);
264+
265+
// Check and adjust overlaps only among visible sidenotes
266+
for (let i = 1; i < visibleContents.length; i++) {
267+
const current = visibleContents[i];
268+
const previous = visibleContents[i - 1];
269+
270+
const currentRect = current.getBoundingClientRect();
271+
const previousRect = previous.getBoundingClientRect();
272+
273+
// If current overlaps with previous, push it down
274+
const overlap = previousRect.bottom - currentRect.top;
275+
if (overlap > 0) {
276+
const currentMarginTop = parseFloat(getComputedStyle(current).marginTop) || 0;
277+
current.style.marginTop = `${currentMarginTop + overlap + 16}px`; // 16px for spacing
278+
}
279+
}
280+
}
281+
282+
// Run on load and resize
283+
adjustSidenotePositions();
284+
window.addEventListener('resize', adjustSidenotePositions);
285+
});
286+
</script>

src/content/logs/2025/12/03.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
date: '2025-12-03T11:43:47Z'
3+
title: '2025-12-03'
4+
draft: false
5+
tags: []
6+
---
7+
8+
import Sidenote from '@components/prose/Sidenote.astro';
9+
10+
I made a `Sidenote` Astro component, inspired by [Tufte CSS](https://edwardtufte.github.io/tufte-css/).
11+
12+
On wide screens, sidenotes appear in the right margin and start visible by default. The dotted underline indicates text with an associated sidenote. You can click the dotted text to toggle the sidenote's visibility with a smooth fade animation. When sidenotes are hidden, other visible sidenotes reposition to stay close to their reference text. Hovering over either the dotted text or the sidenote box highlights both and shows their connection. On narrow screens, clicking the dotted underlined text reveals the note inline with the same smooth fade animation. <Sidenote note="Like this!">It works like this!</Sidenote>
13+
14+
Markdown provides native support for numbered footnotes[^1], but this approach never quite sat well with me.
15+
I like having the additional context available in visual proximity to the text that references it. I want to be able to see both at the same time. Jumping between the two takes me out of the flow of reading. Putting the note in the margin allows me to see and read it if I want, but doesn't interrupt the flow of the text and <Sidenote note="And if the note isn't relevant to me right now, I can just keep reading.">it's still easy to move past if I so choose.</Sidenote>
16+
17+
[^1]: Like this!

0 commit comments

Comments
 (0)