This system uses a combination of Eleventy data, Nunjucks templating, vanilla CSS, and minimal JavaScript to create a performant hover-expand interaction for the portfolio list.
All portfolio data is stored in _data/portfolio.json.
{
"name": "Ansa Biotechnologies",
"url": "https://www.ansabio.com/",
"detail": "Enzymatic DNA synthesis that eliminates the length and accuracy ceiling of phosphoramidite chemistry.",
"press": {
"label": "press",
"url": "https://www.biospace.com/article/releases/ansa-biotechnologies-closes-oversubscribed-68-million-series-a-financing-to-power-the-next-era-of-dna-enabled-industries/?s=80"
},
"acquired": true,
"tba": true
}
detail: The description that appears on expansion. If missing, the item won't be expandable.acquired: Adds an "(Acquired)" badge.tba: Adds a "(TBA)" badge and mutes the styling.The list is rendered in index.html using a Nunjucks loop. Each item with a detail property receives:
class="portfolio-item"aria-expanded="false".portfolio-detail div.The loop also generates the Structured Data (JSON-LD) block at the bottom of the page, ensuring the SEO metadata is always in sync with the visible list.
css/styles.css)aria-expanded="true" and aria-hidden="false".1em square flexbox to ensure rotate(90deg) doesn't cause visual drift.will-change on all elements to conserve GPU memory, as the number of items is small (~15).0fr Grid PatternThe expand/collapse animation uses grid-template-rows: 0fr → 1fr, which is the modern way to animate content to/from an unknown intrinsic height. The structure is:
.portfolio-detail ← grid container, transitions grid-template-rows
.portfolio-detail-text ← grid child, overflow: hidden
How it works: When grid-template-rows is 0fr, the row track collapses to the minimum size of its children. Because .portfolio-detail-text has overflow: hidden, its automatic minimum size resolves to 0 (per CSS Grid spec), so the track can fully collapse.
Critical constraint: The grid child (.portfolio-detail-text) must have zero box-model contribution — no padding, no margin, no border — in its collapsed state. Any of these would create a non-zero minimum height that the grid track cannot shrink below, causing the list item to be taller than items without a detail section.
This is why padding-top is set to 0 by default on .portfolio-detail-text and only applied when .is-open is active:
.portfolio-detail-text {
overflow: hidden; /* allows grid track to collapse to 0 */
padding-top: 0; /* no box-model contribution when collapsed */
}
.portfolio-detail.is-open .portfolio-detail-text {
padding-top: 0.3rem; /* spacing restored when expanded */
}
If you add padding, margin, or border to
.portfolio-detail-text, always guard it behind.is-open. Otherwise the collapsed detail will leak height and break the vertical rhythm of the list.
js/portfolio.js)The interaction is split into two modes:
mouseenter and mouseleave triggers.offsetHeight once at initialization and caches it in a Map.(hover: none) devices, a monospace chevron (▶) is injected via JS.<li> row (number + chevron + text) acts as the toggle trigger for expansion.list-style-position: inside is applied to #portfolio-list on mobile.tabindex="-1" to prevent it from becoming a redundant focus stop.<a> tag within the row still navigates normally; the expansion toggle only triggers if the tap occurs on the number, chevron, or non-link text.pretext or esbuild are required.