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.

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.

Discover more from RSS Feeds Cloud

Subscribe now to keep reading and get access to the full archive.

Continue reading