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?
- 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:
<foreignObject>at all? In my experience, that's the no-no...<style>inside SVG OK? (I did a quick research and did not get clear results, sadly). To me your goal is not entirely clear either, to be honest. And there is a chance than even these possible solutions will turn out as false, so don't hold a high hopes.<style>is allowed, then it should be hackable, provided the contents of the style is not sanitised for weird stuff. For a plain HTML (what you actually do NOT need) the answer could look like this: codepen.io/myf/pen/QwwwNdo , right?