
It’s ideal for settings panels, workflow maps, and form steps where the current choice needs a visible path back to its related items.
Features:
- Based on the native CSS anchor-positioning API.
- Supports multiple simultaneous connections, each scoped independently to its own source element.
- Draws two-segment connectors that route automatically based on whether the source sits above, below, or at the same vertical level as the target.
- Full theming control through CSS custom properties for line color, stroke width, corner radius, and panel gap.
- Position-aware connector geometry adjusts curve direction and corner rounding per segment for each positional case.
- All connector layout and geometry run in CSS.
- Automatic fallback message displays in legacy browsers.
How to use it:
1. Create two <ul> elements inside a shared container: one for checkboxes (.items) and one for radio buttons (.options).
<div id="linked-list" class="linked-list">
<!-- Checkbox list — multiple items can be checked simultaneously -->
<ul class="list items" aria-labelledby="items-label">
<li>
<label for="task-1">
<input type="checkbox" id="task-1" class="cb" value="1" checked> Task Alpha
</label>
</li>
<li>
<label for="task-2">
<input type="checkbox" id="task-2" class="cb" value="2"> Task Beta
</label>
</li>
<li>
<label for="task-3">
<input type="checkbox" id="task-3" class="cb" value="3" checked> Task Gamma
</label>
</li>
<li>
<label for="task-4">
<input type="checkbox" id="task-4" class="cb" value="4"> Task Delta
</label>
</li>
</ul>
<!-- Radio list — only one option can be selected at a time -->
<ul class="list options" role="radiogroup" aria-labelledby="options-label">
<li>
<label for="phase-1">
<input type="radio" id="phase-1" name="phase" class="rb" value="1"> Phase A
</label>
</li>
<li>
<label for="phase-2">
<input type="radio" id="phase-2" name="phase" class="rb" value="2"> Phase B
</label>
</li>
<li>
<label for="phase-3">
<input type="radio" id="phase-3" name="phase" class="rb" value="3" checked> Phase C
</label>
</li>
<li>
<label for="phase-4">
<input type="radio" id="phase-4" name="phase" class="rb" value="4"> Phase D
</label>
</li>
<li>
<label for="phase-5">
<input type="radio" id="phase-5" name="phase" class="rb" value="5"> Phase E
</label>
</li>
</ul>
</div>2. Add the CSS. The two anchoring rules are the heart of the pattern. anchor-name: --checked-option on the checked checkbox label registers it as the connector’s source point. anchor-name: --radio-option on the checked radio li registers it as the target. All segment edges are derived from the anchor() function referencing those two named points.
@layer base, demo;
@layer demo {
.linked-list {
/* — Theming variables — adjust to match your design system — */
--checkbox-checked-border-color: dodgerblue;
--radio-checked-border-color: dodgerblue;
--join-stroke: 1px;
--join-line: var(--join-stroke) dashed dodgerblue; /* Connector line style */
--join-radius: 20px; /* Corner rounding on L-shaped bends */
--gap: 10vw; /* Horizontal gap between the two columns */
position: relative;
display: grid;
align-items: start;
grid-template-columns: 1fr 1fr; /* Two equal columns */
width: min(100%, 800px);
gap: var(--gap);
}
.list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: .5rem;
}
.list > li {
border: 1px solid var(--clr-lines);
display: flex;
align-items: center;
}
.list > li > label {
flex: 1;
padding: .5rem;
cursor: pointer;
}
/* Checked checkbox label registers as the named anchor source */
.list.items li:has(:checked) label {
anchor-name: --checked-option;
/* anchor-scope scopes this name to the local subtree so multiple
checked checkboxes each get their own independent anchor chain */
anchor-scope: --checked-option;
border-color: var(--checkbox-checked-border-color);
}
/* First segment: spans from the right edge of the checkbox to the midpoint column */
.list.items li:has(:checked) label::before,
.list.items li:has(:checked) label::after {
content: '';
position: absolute;
border: var(--join-line);
right: calc(anchor(left --radio-option) + var(--gap) / 2);
left: anchor(right --checked-option); /* Left edge aligns to checkbox right */
}
/* Second segment: spans from the midpoint column to the left edge of the radio option */
.list.items li:has(:checked) label::after {
right: anchor(left --radio-option);
left: calc(anchor(left --radio-option) - var(--gap) / 2 - var(--join-stroke));
}
/* — Routing case 1: checkbox sits ABOVE the radio button — */
@container style(--relation: -1) {
.list.items li:has(:checked) label::before {
border-left-color: transparent;
border-bottom-color: transparent;
border-radius: 0 var(--join-radius) 0 0; /* Rounds the top-right corner */
top: anchor(center --checked-option);
bottom: anchor(top --radio-option);
}
.list.items li:has(:checked) label::after {
border-right-color: transparent;
border-top-color: transparent;
border-radius: 0 0 0 var(--join-radius); /* Rounds the bottom-left corner */
top: calc(anchor(top --radio-option) - var(--join-stroke));
bottom: anchor(center --radio-option);
}
}
/* — Routing case 2: checkbox sits BELOW the radio button — */
@container style(--relation: 1) {
.list.items li:has(:checked) label::before {
border-left-color: transparent;
border-top-color: transparent;
border-radius: 0 0 var(--join-radius) 0; /* Rounds the bottom-right corner */
top: anchor(bottom --radio-option);
bottom: anchor(center --checked-option);
}
.list.items li:has(:checked) label::after {
border-right-color: transparent;
border-bottom-color: transparent;
border-radius: var(--join-radius) 0 0 0; /* Rounds the top-left corner */
top: anchor(center --radio-option);
bottom: calc(anchor(bottom --radio-option) - var(--join-stroke));
}
}
/* — Routing case 3: checkbox and radio share the same vertical index — */
@container style(--relation: 0) {
.list.items li:has(:checked) label::before {
/* Straight horizontal connector — no bends or radius needed */
border-top-color: transparent;
border-right-color: transparent;
border-left-color: transparent;
border-radius: 0;
top: calc(anchor(center --radio-option) - var(--join-stroke));
bottom: calc(anchor(center --checked-option) + var(--join-stroke));
right: anchor(left --radio-option);
left: anchor(right --checked-option);
}
.list.items li:has(:checked) label::after {
display: none; /* Second segment is not needed for a straight line */
}
}
/* Checked radio option registers as the named anchor target */
.list.options li:has(:checked) {
anchor-name: --radio-option;
border-color: var(--radio-checked-border-color);
}
}
/* — Base layer: general layout and color-scheme — */
@layer base {
* { box-sizing: border-box; }
:root {
color-scheme: light dark;
--bg-dark: rgb(16, 24, 40);
--bg-light: rgb(248, 244, 238);
--txt-light: rgb(10, 10, 10);
--txt-dark: rgb(245, 245, 245);
--line-light: rgba(0 0 0 / .25);
--line-dark: rgba(255 255 255 / .25);
/* light-dark() picks the first value in light mode, second in dark mode */
--clr-bg: light-dark(var(--bg-light), var(--bg-dark));
--clr-txt: light-dark(var(--txt-light), var(--txt-dark));
--clr-lines: light-dark(var(--line-light), var(--line-dark));
}
body {
background-color: var(--clr-bg);
color: var(--clr-txt);
min-height: 100svh;
margin: 0;
padding: 2rem;
font-family: "Jura", sans-serif;
display: grid;
place-content: center;
gap: 2rem;
}
/* Fallback notice for browsers without anchor-positioning support */
@supports not (position-anchor: --test) {
body::before {
content: "Your browser does not support CSS anchor-positioning";
position: fixed;
top: 2rem;
left: 50%;
translate: -50% 0;
font-size: 0.8rem;
}
}
}3. Determine whether each checkbox row sits above, below, or at the same index as the currently selected radio option, then write that relationship as a CSS custom property.
const root = document.querySelector('.linked-list');
// Maps the three possible Math.sign() outputs to CSS --relation values
// Index 0 = above (-1), Index 1 = same (0), Index 2 = below (1)
const REL = ['-1', '0', '1'];
function updateRelations() {
// Find the currently selected radio option row
const radioLi = root.querySelector('.options li:has(input:checked)');
if (!radioLi) return;
// Get the zero-based position of that row in the radio list
const radioIndex = [...radioLi.parentElement.children].indexOf(radioLi);
// Write --relation to every checkbox row based on its position vs. the radio
root.querySelectorAll('.items li').forEach((li, i) => {
// Math.sign returns -1 (above), 0 (same level), or 1 (below)
// Adding 1 shifts the result to 0, 1, or 2 for array indexing
li.style.setProperty('--relation', REL[Math.sign(i - radioIndex) + 1]);
});
}
// Re-run on any radio or checkbox change inside the container
root.addEventListener('change', updateRelations);
// Set the initial --relation values on page load
updateRelations();The post Linked Lists with CSS Anchor Positioning appeared first on CSS Script.
Discover more from RSS Feeds Cloud
Subscribe to get the latest posts sent to your email.
