/*
a simple reset, in which I ensure that all elements are sized
in the same way, following the border-box algorithm which
includes assigned padding, margins, and border-width within
the assigned size of the element. I'm also setting the font
of the document to remove browser defaults:
*/
*,
::before,
::after {
box-sizing: border-box;
font-family: system-ui;
font-size: 1rem;
font-weight: 400;
margin: 0;
padding: 0;
}
/* forcing the <html> element to occupy the full size of the
viewport's block-axis (the axis upon which 'blocks' are
positioned according to the language choice of the user): */
html {
block-size: 100%;
}
/* defining some custom properties to help lay out the contents,
and its spacing: */
body {
--space-s: calc(var(--space) * 0.5);
--space: 0.5rem;
--space-l: calc(var(--space) * 2);
/* 1vb is 1% of the element's size on the block-axis, therefore
this rule sizes the body to take up the full size of the
viewport: */
min-block-size: 100vb;
/* setting the padding of the element, using the CSS var()
function along with the defined CSS custom property: */
padding: var(--space-l);
}
main {
/* as above, sizing the block-axis of the element to take 100%
of the available space: */
min-block-size: 100%;
border: 1px solid currentColor;
padding: var(--space-l);
}
h1 {
/* styling the font of the <h1> after forcing all
fonts (earlier) to be only 1rem, here we double
that size to emphasise the heading: */
font-size: 2rem;
/* setting a margin on the block-end (the side of
the element that would be nearest the next block-
level sibling element) to be in proportion to
the font-size of the current element: */
margin-block-end: 0.5em;
}
h2::after {
content: ':';
}
form {
/* using grid layout: */
display: grid;
/* setting the gap between adjacent elements within
the grid: */
gap: var(--space-l);
}
/* styling any <button> element within any <form> that
has any :invalid form-elements (<input>, <textarea>...): */
form:has(:invalid) button {
/* highlighting that clicking the button is disallowed: */
cursor: not-allowed;
/* reducing the opacity to imply that the element is
disabled: */
opacity: 0.5;
/* it would have been possible to use:
pointer-events: none;
but unfortunately that prevents the :hover pseudo-class
from matching, which means the cursor wouldn't be
changed. This is the choice I made, you may feel
that preventing user-action on the <button> is worth
that limitation. */
}
fieldset {
--indicator: hsl(0deg 60% 40% / 0.8);
align-items: center;
display: flex;
flex-flow: row wrap;
gap: var(--space-l);
isolation: isolate;
padding-block: var(--space);
padding-inline: var(--space-l);
position: relative;
}
fieldset::after {
/* I freely admit I went a bit overboard...
here we're using a linear-gradient as a background image,
the gradient running 90degrees (to the right, 0degrees is
vertical), we have transparent from 0 running to 70% of the
background-size, after which we use the colour assigned to
the custom CSS property --indicator or, if that property isn't
defined, the default colour of transparent.
The background is positioned at 100% (x-axis) and 50% (y-axis),
and sized 100% in both horizontal and vertical axes (respectively),
and we set no-repeat so that the image doesn't repeat: */
background: linear-gradient(90deg, transparent 0 70%, var(--indicator, transparent)) 100% 50% / 100% 100% no-repeat;
/* to occlude the error gradient we use the clip-path, which allows
us to limit the visibility of the element according to the
shape we define. We're using the polygon() function here,
which takes a list of comma-separated positions (for x and y
respectively), to define a number of points; within the space
formed by those points the content is visible. Outside, the
content is hidden ('clipped'): */
clip-path:
polygon(
100% 0,
100% 0,
100% 100%,
100% 100%
);
/* content is a mandatory property to have the pseudo-element
be rendered, an empty string is valid 'content' for this: */
content: '';
/* using inset to specify the position of the pseudo-element in
reference to its containing block, here it's positioned 0
distance from each edge (top, right, bottom, left respectively)
*/
inset: 0;
position: absolute;
/* we're transitioning the clip-path property, over 500ms and
with a linear easing function: */
transition: clip-path 500ms linear;
/* to position the pseudo-element behind all content of its
containing block ancestor, appearing in front of only
that element's background: */
z-index: -1;
}
/* styling the ::after pseudo-element of any <fieldset> that contains
a :user-invalid form-element: */
fieldset:has(:user-invalid)::after {
clip-path: polygon( 0 0, 100% 0, 100% 100%, 0 100%);
}
label {
/* using a non-'static' (the default) value for the
position property, in order that this element becomes
the containing block for its descendants: */
position: relative;
}
/* the element that contains the text associated with each
<input> element: */
.labelText {
/* various CSS custom properties: */
--base-h: 0deg;
--base-s: 20%;
--base-l: 30%;
--base-z-pos: 0px;
--border-color: hsl(var(--base-h) var(--base-s) var(--base-l) / 1);
--offset-x: 0px;
--offset-y: 6px;
--spread: 0.3rem;
/* to ensure that the element is sized to have equal size on both
its block, and inline, axes: */
aspect-ratio: 1;
/* a subtle background, I left it in but it's not all that visible: */
background: repeating-linear-gradient(135deg, snow 0 0.5rem, azure 0.5rem 0.75rem);
border: 2px solid var(--border-color);
/* defining the block-size of the element, which will influence the
inline-size via the aspect-ratio property: */
block-size: 3.5rem;
display: grid;
/* using drop-shadow in place of box-shadow, as it's a little more accurate
(if you remove the background of this element, the visible content of the
element generates an accurate shadow, whereas box-shadow gives a shadow to
the outer shape of the element, effectively just a rectangular polygon): */
filter: drop-shadow(var(--offset-x) var(--offset-y) var(--spread) var(--border-color));
/* centering the content within the element: */
place-content: center;
/* using perspective and translateZ() to move the element somewhat
faithfully in 3d 'space': */
transform: perspective(500px) translateZ(var(--base-z-pos));
/* required to enable any sort of accuracy within the 3d space: */
transform-style: preserve-3d;
/* defining a transition to apply to every (animatable) property: */
transition: all 300ms ease-in-out;
/* defining the precise properties to transition: */
transition-property: transform, filter;
}
/* updating CSS properties for:
any .labelText element when hovered: */
.labelText:hover,
/* any .labelText element that is immediately preceded
by an <input> which is in any of the states listed
within the :is() pseudo-class function: */
input:is(:active, :focus, :checked) + .labelText {
--base-h: 120deg;
--base-z-pos: -50px;
}
/* as above, but to specify different properties that
I didn't want available to the .labelText:hover: */
input:is(:active, :focus, :checked) + .labelText {
--base-s: 40%;
--base-l: 60%;
--base-z-pos: -80px;
}
/* we're hiding the <input>, since we're "replacing" it
with the <span>: */
input {
/* scaling it very, very, very small: */
scale: 0.01;
/* using absolute position to remove the element from
the document flow: */
position: absolute;
}
.error {
background-color: snow;
border: 2px solid currentColor;
/* using a large border-radius to have the browser
style the borders into a 'pill' shape, with
rounded ends: */
border-radius: 5rem;
/* using inset to position the element with reference
to the containing block, here the 'auto' values
are equivalent to not setting a property and allowing
the browser to calculate it, but all values must be
provided so we have to specify a value; -150% positions
the pseudo-element's right side 150% outside of the
containing block. Negative numbers move away from the
center of the containing block, positive numbers
move towards the center (on the relevant axis): */
inset: auto -150% auto auto;
padding-block: var(--space-s);
padding-inline: var(--space);
position: absolute;
transition: inset 500ms linear;
}
/* :user-invalid matches form-elements that have been given,
or retained invalid values due to the action/inaction of
the user; the pseudo-class only matches elements after
the user attempts to submit the form; with this selector
we're seleing any .error message within a <fieldset> that
itself contains an <input> which is invalid following the
user's attempt to submit the form: */
fieldset:has(input:user-invalid) .error {
inset: auto 20% auto auto;
}
<main>
<h1>Rhesus Genotype Analyzer</h1>
<form method="POST" action="/">
<fieldset>
<!-- I opted to use an <h2> element to label the grouped elements,
as a <legend> wouldn't be styled as you seem to wish: -->
<h2>D</h2>
<!-- using <label> elements to associate the text, in the nested <span>
with the <input>, no "for" attribute is required as the relationship
is implicit due to the <input> being nested within the <label> -->
<label>
<!-- the <span> follows the <input> in order to use
the validity, and user-interactivity, pseudo-
classes of the <input> to style the adjacent
<span>: -->
<input type="radio" value="pos" name="D" required="">
<span class="labelText">pos</span>
</label>
<label>
<input type="radio" value="neg" name="D">
<span class="labelText">neg</span>
</label>
<!-- using a <span> to contain an error message in the event that the
user tries to submit an incomplete form: -->
<span class="error">This field is mandatory, please select one of the options.</span>
</fieldset>
<fieldset>
<h2>C</h2>
<label>
<input type="radio" value="pos" name="C" required="">
<span class="labelText">pos</span>
</label>
<label>
<input type="radio" value="neg" name="C">
<span class="labelText">neg</span>
</label>
<span class="error">This field is mandatory, please select one of the options.</span>
</fieldset>
<fieldset>
<h2>E</h2>
<label>
<input type="radio" value="pos" name="E" required="">
<span class="labelText">pos</span>
</label>
<label>
<input type="radio" value="neg" name="E">
<span class="labelText">neg</span>
</label>
<span class="error">This field is mandatory, please select one of the options.</span>
</fieldset>
<fieldset>
<h2>c</h2>
<label>
<input type="radio" value="pos" name="c" required="">
<span class="labelText">pos</span>
</label>
<label>
<input type="radio" value="neg" name="c">
<span class="labelText">neg</span>
</label>
<span class="error">This field is mandatory, please select one of the options.</span>
</fieldset>
<fieldset>
<h2>e</h2>
<label>
<input type="radio" value="pos" name="e" required="">
<span class="labelText">pos</span>
</label>
<label>
<input type="radio" value="neg" name="e">
<span class="labelText">neg</span>
</label>
<span class="error">This field is mandatory, please select one of the options.</span>
</fieldset>
<!-- rather than use an <input> to submit the form, I opted to use
a <button>; the default action of a <button> within a <form> is
to submit the form, which is why there's no explicit 'submit'
attribute: -->
<button>Submit</button>
</form>
</main>