Categories: CSSScriptWeb Design

Linked Lists with CSS Anchor Positioning

Linked Lists is a JavaScript & CSS UI component that connects multiple checked list items to a selected radio option with dashed relation lines.

It’s ideal for settings panels, workflow maps, and form steps where the current choice needs a visible path back to its related items.

Table of Contents

Toggle

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.

rssfeeds-admin

Share
Published by
rssfeeds-admin

Recent Posts

Two Direction Sticky Sidebar For Vanilla JS

Two Direction Sticky Sidebar is a pure JavaScript utility that implements bi-directional sticky sidebar behavior…

1 hour ago

Two Direction Sticky Sidebar For Vanilla JS

Two Direction Sticky Sidebar is a pure JavaScript utility that implements bi-directional sticky sidebar behavior…

1 hour ago

Whitmer Expands Michigan Emergency as Flooding and Tornadoes Hammer State

LANSING, MI (WOWO) Governor Gretchen Whitmer has expanded Michigan’s state of emergency as severe weather…

2 hours ago

Michigan Prison Conditions Under Scrutiny Amid Clemency Request

LANSING, MI (WOWO) Advocates and lawmakers are urging Michigan Governor Gretchen Whitmer to grant clemency…

2 hours ago

PoC Exploit Released for Windows Snipping Tool NTLM Hash Leak Vulnerability

A proof-of-concept (PoC) exploit has been publicly released for a newly disclosed vulnerability in Microsoft’s…

2 hours ago

Lawmakers Support Major Investment in Indiana Child Care Voucher Program

INDIANAPOLIS, IND. (WOWO) State leaders in Indiana are supporting a major new investment aimed at…

2 hours ago

This website uses cookies.