Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Interactive SVG without SMIL, JS, foreignObject, and :checked

Tags:

css

checkbox

svg

I'd like to contribute an interactive SVG graph to Wikisource. There are quite strict requirements on the format, as indicated in the title:

  • SVG can contain inline style attributes and <style> elements, but besides that:
  • no SMIL,
  • no JavaScript,
  • no HTML foreignObject isles,
  • so no HTML <input> and CSS :checked tricks.

I know about the :root:has(input[…]:checked) something {…} toggling technique, as seen in this HTML sample:

#main:has(#chkbox1)          #triangle  { display:none; }
#main:has(#chkbox1:checked)  #triangle  { display:block; }

#main:has(#chkbox2)          #square    { display:none; }
#main:has(#chkbox2:checked)  #square    { display:block; }

#main:has(#chkbox3)          #circle    { display:none; }
#main:has(#chkbox3:checked)  #circle    { display:block; }
<div id="main">
  <div id="triangle">Triangle</div>
  <div id="square">Square</div>
  <div id="circle">Circle</div>

  <input type="checkbox" id="chkbox1" checked="true">
  <input type="checkbox" id="chkbox2" checked="true">
  <input type="checkbox" id="chkbox3" checked="true">
</div>

(As you can see, this works without using JavaScript.) But it is just a HTML, that does not fit the requirements.

How can I simulate same functionality (hiding/showing some elements) without using any <input> tags and without using JavaScript?

I aim for a solution in which each <input> is replaced by some SVG node that simulates a checkbox using only allowed techniques, i.e., presumably CSS.

Here is an image of SVG generated on CodePen with the SVG I'd like to contribute, including the interactivity: https://codepen.io/schlebe/pen/gbbOrYK

Complex graph with several colourful function progressions, each toggled with a corresponding checkbox located on the right side.

like image 295
schlebe Avatar asked Dec 22 '25 06:12

schlebe


1 Answers

Kaiido's ":target-based" answer is great, mainly because it preserves accessibility to a large extent (even keyboard!), and has neat ability to address any state using URL#hash so is indeed preferable.

Adding this alternative approach that is comparatively simpler, since introducing additional control here requires linear or (even constant, see below), not exponential additions.

Flipping "display" with :active and spring-loaded animation

It exploits super-short paused animations and :active trigger that releases the animation with animation-play-state: running. Animation runs and the state is kept thanks to the animation-fill-mode: both. For returning back to the original state, animation: none is used, what after release basically resets the animation timeline back to start, when the original animation is re-applied.

As extra challenge, this sample does not even use :has(), so it's CSS is pretty old-school. Also for simplicity, the "Trigger" elements are just on/of (filled/empty) rectangles superimposed over each other, not mutually toggled.

<embed height="150" src='data:image/svg+xml,<svg
 xmlns="http://www.w3.org/2000/svg"
 viewBox="0 0 100 50"
 fill="none"
 pointer-events="all"
><style>
  /* Using scale-to-zero for hiding, see notes */
  @keyframes hide { to { transform: scale(0) } }
  /* "Spring-loaded" animation */
  path, rect { animation: .1ms hide both paused }
  /* "Hide triggers" above "Show triggers" hide themselves and path */
  rect[fill="red"]:active,
  rect[fill="red"]:active ~ path[stroke="red"] ,
  rect[fill="green"]:active,
  rect[fill="green"]:active ~ path[stroke="green"] {
   animation-play-state: running
  }
  /* "Show trigger" underneath following "Hide trigger" show it and path */
  rect[stroke="red"]:active + rect,
  rect[stroke="red"]:active ~ path[stroke="red"] ,
  rect[stroke="green"]:active + rect,
  rect[stroke="green"]:active ~ path[stroke="green"] {
   animation: none
  }
 </style>
 <!-- Show/Hide "Triggers" -->
 <rect stroke="red" width="30" height="20" x="63" y="3" />
 <rect fill="red" width="30" height="20" x="63" y="3" />
 <rect stroke="green" width="30" height="20" x="63" y="27" />
 <rect fill="green" width="30" height="20" x="63" y="27" />
 <!-- "Graph" -->
 <path stroke="red" d="M0 0 l 20 10 40 40" />
 <path stroke="green" d="M0 50 l 20 -30 40 -20" />
</svg>'>

As indicated in the prologue, this approach is not keyboard-accessible. It is possible to add tabindex="0", or fuse it with <a href="#">nchors, but sadly, keyboard interaction (Enter/Spacebar) does not trigger the :active state the way pointer does. (I guess it should not be this way, but the reality is like it is.)

Variation with constant CSS complexity

And as Kaiido suggested, altering of the source structure by intermixing controls among the target "graph" elements unlocks a major simplification: effectively making the CSS static, i.e., not needing alterations when introducing new interactive content.

Resulting sample with few more shenanigans making the SVG code itself as terse as possible, and without need for repeating anything in the CSS, this time as a regular SO HTML snippet:

@keyframes hide {
 to { transform: scale(0) }
}
path,
rect {
 animation: .1ms hide both paused;
 stroke: currentcolor;
}
rect + rect {
 fill: currentcolor;
}
rect + rect:active,
rect + rect:active + path {
 animation-play-state: running;
}
rect:active + rect,
rect:active + rect + path {
 animation: none;
}
svg {
 fill: none;
 pointer-events: all;
}
rect {
 width: 30px;
 --stroke: 2px;
 stroke-width: var(--stroke);
 height: calc((100% / var(--count)) - 2 * (var(--stroke)));
 x: 63px;
 y: calc(100% / var(--count) * var(--index) + var(--stroke));
}
/*
 Just emulating "sibling count" and "sibling index" for [1..4]
 Cannot wait to actually have it in the CSS
*/
g { --count: 1; --index: 0; }
g:nth-child(2) { --index: 1; }
g:nth-child(3) { --index: 2; }
g:nth-child(4) { --index: 3; }
g:first-child:nth-last-child(2){ &, & ~ g { --count: 2; } }
g:first-child:nth-last-child(3){ &, & ~ g { --count: 3; } }
g:first-child:nth-last-child(4){ &, & ~ g { --count: 4; } }
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 50" height="150">
<g>
 <g color="green"><rect /><rect />
  <path d="M0 50 l 20 -30 40 -20" />
 </g>
 <g color="gold"><rect /><rect />
  <path d="M0 25 l 20 10 40 -10" />
 </g>
 <g color="red"><rect /><rect />
  <path d="M0 0 l 20 10 40 40" />
 </g>
</g>
<!--
 Style moved outside, for nicer SO snippet.
-->
</svg>

(There is just unrelated "sibling count" emulation for making even the placement of "buttons" automatic, that uses nesting for brevity. Besides that the SVG demands on the CSS capabilities remains pretty basic.)

Declarative on/off states

For implementing declarative initial on-off state, we can use some data-attribute as the directive (data-initial="off" here) and flip the direction and button effects. For brevity, custom properties with fallback defaults come in handy:

@keyframes hide {
 to { transform: scale(0); }
}
rect + rect,
rect + rect + path {
 animation: .1ms hide both paused var(--dir, normal);
}
rect:active + rect,
rect:active + rect + path {
 animation-play-state: running;
 animation-name: var(--bottom-act, none);
}
rect + rect:active,
rect + rect:active + path {
 animation-play-state: running;
 animation-name: var(--top-act, hide);
}
[data-initial="off"] {
 --dir: reverse;
 --top-act: none;
 --bottom-act: hide;
}

path, 
rect {
 stroke: currentcolor;
}
rect + rect {
 fill: currentcolor;
}
svg {
 fill: none;
 pointer-events: all;
}
rect {
 width: 30px;
 --stroke: 2px;
 stroke-width: var(--stroke);
 height: calc((100% / var(--count)) - 2 * (var(--stroke)));
 x: 63px;
 y: calc(100% / var(--count) * var(--index) + var(--stroke));
}
/*
 Just emulating "sibling count" and "sibling index" for [1..4]
 Cannot wait to actually have it in the CSS
*/
g { --count: 1; --index: 0; }
g:nth-child(2) { --index: 1; }
g:nth-child(3) { --index: 2; }
g:nth-child(4) { --index: 3; }
g:first-child:nth-last-child(2){ &, & ~ g { --count: 2; } }
g:first-child:nth-last-child(3){ &, & ~ g { --count: 3; } }
g:first-child:nth-last-child(4){ &, & ~ g { --count: 4; } }
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 50" height="150">
<g>
 <g color="green">
  <rect /><rect />
  <path d="M0 50 l 20 -30 40 -20" />
 </g>
 <g color="gold">
  <rect /><rect />
  <path d="M0 25 l 20 10 40 -10" />
 </g>
 <g color="red" data-initial="off">
  <rect /><rect />
  <path d="M0 0 l 20 10 40 40" />
 </g>
</g>
<!--
 Style moved outside, for nicer SO snippet.
-->
</svg>

Notes, Q/A:

  • Why transform: scale(0) for hiding and not display: none?
    • Because in Firefox, display is not animatable yet.
  • Why it's not "keyboard accessible"?
    • This is a long-standing issue. Currently (2025-05) the only browser-element combo that reflects keyboard-induced :active state, is Chrome-button. Specs are somewhat lax about this topic. There are some quite stalled discussion in the CSS working group's GH:
      • [css-selectors-3] Make :active specification more explicit as to which interactions cause its application #4787 where it currently stopped at "not a spec problem, but implementers'", and
      • [selectors] Should `:active match spacebar down? #7332 that is more recent and more focused, but also without any progress.
like image 141
myf Avatar answered Dec 23 '25 23:12

myf



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!