Refining the Portfolio: Table of Contents, Button Consistency, and Visual Hierarchy
On This Page
After launching the initial responsive design for my portfolio, I spent another session focused on polish: adding navigation aids, creating visual consistency, and establishing clearer hierarchy. This post documents the implementation of a sticky table of contents, button style unification, and category badge refinement.
The Challenge: Navigation and Visual Consistency
Two problems became apparent after the responsive redesign:
-
Long-form content needed structure - Project writeups ran hundreds of lines. Readers had no way to jump to specific sections or track their position in the document.
-
Visual hierarchy was muddy - Category badges, tech tags, and action buttons all competed for attention. Nothing stood out as the primary call to action.
The solution required balancing functionality (navigation) with aesthetics (clean visual hierarchy).
Table of Contents: Desktop Sidebar, Mobile Inline
Design Requirements
The table of contents needed to:
- Auto-generate from heading structure
- Stay visible while scrolling (desktop)
- Highlight the current section
- Position appropriately on mobile without cluttering the header
Implementation Approach
I built a JavaScript-powered TOC that adapts to viewport size:
Desktop (≥1024px): Sticky sidebar using CSS Grid
.page-container {
display: grid;
grid-template-columns: 280px 1fr;
gap: 2.5rem;
}
.toc-wrapper {
position: sticky;
top: 80px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
Mobile (<1024px): Inline between metadata and content
const isMobile = window.innerWidth < 1024;
if (isMobile && titleSection) {
// Insert after project metadata
titleSection.parentElement?.insertBefore(tocWrapper, titleSection.nextSibling);
} else if (pageContainer) {
// Insert as first grid column
pageContainer.insertBefore(tocWrapper, pageContainer.firstChild);
}
Scroll Spy with Intersection Observer
The TOC highlights the current section using the Intersection Observer API:
const observerOptions = {
rootMargin: '-100px 0px -66%',
threshold: 0
};
const observerCallback = (entries) => {
entries.forEach((entry) => {
const id = entry.target.getAttribute('id');
const link = tocNav?.querySelector(`a[href="#${id}"]`);
if (entry.isIntersecting) {
tocNav?.querySelectorAll('a').forEach((a) => a.classList.remove('active'));
link?.classList.add('active');
}
});
};
The rootMargin creates a “viewport sweet spot” where headings trigger active state changes when they’re roughly 100px from the top and still 66% visible.
Handling Long Headings
Some technical headings exceeded the sidebar width. I used CSS line clamping with a hover title for full text:
.toc-nav a {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
a.title = headingText; // Full text on hover
Button Unification: Consistent Calls to Action
The Problem
The portfolio had three button styles:
- Homepage “Contact Me” - green with shadow and lift effect
- Project “Visit Site” - blue background, basic hover
- Project “View GitHub” - green background, opacity hover
This inconsistency undermined the design’s credibility. Users expect buttons to behave similarly across a site.
The Solution: One Button Style
I standardized all action buttons to match the Contact Me style:
.live-link,
.repo-link,
.contact-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(74, 103, 65, 0.2);
}
.live-link:hover,
.repo-link:hover,
.contact-button:hover {
background: var(--accent-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 103, 65, 0.3);
}
This creates a cohesive experience where all actions feel like part of the same system.
Inline Metadata Positioning
Previously, project links appeared in a separate section below tech stack tags. This created unnecessary vertical distance and fragmented the metadata area.
I moved them inline with Status, Version, and Started fields:
<div class="meta">
<div class="meta-item">
<span class="category">{categoryLabels[category]}</span>
</div>
<div class="meta-item">
Status: <span class={`status ${status}`}>{status.replace('-', ' ')}</span>
</div>
{/* ... other metadata ... */}
{liveUrl && (
<div class="meta-item">
<a href={liveUrl} class="live-link" target="_blank">
🌐 Visit Site
</a>
</div>
)}
</div>
The flexbox layout handles wrapping automatically, keeping everything scannable.
Visual Hierarchy: Category Badges vs Action Buttons
The Conflict
With buttons now using solid green backgrounds, category badges (also solid green) competed for attention. Everything looked equally important, which meant nothing stood out.
The Fix: Semi-Transparent Categories
I reduced category badge opacity to 65% while keeping buttons at 100%:
.category {
background: rgba(74, 103, 65, 0.65); /* Faded */
color: white;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.live-link,
.repo-link {
background: var(--accent); /* Solid */
}
This creates a clear hierarchy:
- Action buttons (solid green) - Primary interactive elements
- Category badges (faded green) - Informational labels
- Tech tags (light gray) - Secondary information
Responsive Project Grid: Three Columns to One
The projects listing page needed a more controlled layout. The original auto-fit grid was unpredictable at certain viewport widths.
Explicit Column Breakpoints
I replaced automatic grid with explicit breakpoints:
/* Desktop: 3 columns */
ul {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
/* Tablet: 2 columns */
@media (min-width: 768px) and (max-width: 1023px) {
ul {
grid-template-columns: repeat(2, 1fr);
}
}
/* Mobile: 1 column */
@media (max-width: 767px) {
ul {
grid-template-columns: 1fr;
}
}
Viewport-Relative Container
To prevent horizontal scrolling on narrow viewports, I used min() for responsive width:
main {
width: min(1200px, 95%);
margin: 0 auto;
}
This means:
- Viewports ≤ 1200px: Container is 95% width (2.5% margin each side)
- Viewports > 1200px: Container caps at 1200px
No more horizontal scrollbars, no more content cut off at screen edges.
Additional Polish
Email Icon in Header
Added a mailto link to the header social icons:
<a href="mailto:user@example.com">
<span class="sr-only">Send email</span>
<svg viewBox="0 0 24 24"><!-- mail icon --></svg>
</a>
Sticky Footer
Implemented flexbox sticky footer to prevent awkward spacing on short pages:
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
}
Lessons Learned
-
Navigation aids are non-negotiable for long content - The TOC immediately made project writeups more accessible. Before, readers had to scroll blindly; now they can jump directly to relevant sections.
-
Consistency builds trust - Unifying button styles seems minor, but inconsistent UI signals lack of attention to detail. Users notice.
-
Visual hierarchy requires restraint - Making everything bold and colorful means nothing stands out. Strategic use of opacity and size creates clear priority.
-
Responsive design is about control - Auto-fitting grids are convenient but unpredictable. Explicit breakpoints give you control over the user experience at every viewport size.
-
The details compound - Sticky footer, truncated headings, viewport-relative widths—none of these alone transform the experience, but together they eliminate friction.
What’s Next
The portfolio now has solid information architecture and consistent UI patterns. Next priorities:
- Performance audit - Run Lighthouse, optimize images, check bundle size
- Content expansion - Add more project writeups, document recent lab work
- Accessibility review - Test with screen readers, verify keyboard navigation
The foundation is stable. Time to build on it.
All code for this portfolio is open source at github.com/hmbldv/portfolio. The site is built with Astro and deployed on Vercel.