bottega

primitives

Glitch Radio Group

A cyberpunk-styled radio button group where each option is rendered as an asymmetric clip-path polygon button with a neon shadow offset layer. On hover and when checked, a scan-line glitch overlay appears — animating a shimmy effect that cycles through seven clip-path slices with rapid horizontal translate jitter, evoking a CRT screen artifact. The entire interaction is CSS-only via adjacent-sibling selectors (input:checked + .btn, input:hover + .btn); no JS or pointer tracking needed. Each option uses a visually-hidden native <input type="radio"> stretched over the wrapper for full a11y, keyboard operability, and form semantics, with a visible focus-visible ring on keyboard focus. Token-mapped: --primary drives the fill, a color-mixed variant of --primary drives the shadow offset, and --primary-foreground provides the label color.

Ported from andrew-demchenk0 (MIT)


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/glitch-radio-group

Usage

Usagetsx
<GlitchRadioGroup value="" onChange={undefined} options={[]} name="" />

Props

PropTypeDefaultDescription
value*stringControlled selected value.
onChange*(value: string) => voidCalled when the user selects an option.
options*GlitchRadioOption[]The set of options to render.
name*stringShared `name` attribute for the radio group (important for form semantics).
classNamestringAdditional class names merged onto the group root.

Source

glitch-radio-group.tsxtsx
import type React from "react";
import styles from "./glitch-radio-group.module.css";

export type GlitchRadioOption = {
  /** The value submitted with the form. */
  value: string;
  /** Human-readable label rendered inside the button face. */
  label: string;
  /** Short tag rendered in the top-right corner badge (e.g. "r1"). Defaults to option index. */
  tag?: string;
};

export type GlitchRadioGroupProps = {
  /** Controlled selected value. */
  value: string;
  /** Called when the user selects an option. */
  onChange: (value: string) => void;
  /** The set of options to render. */
  options: GlitchRadioOption[];
  /** Shared `name` attribute for the radio group (important for form semantics). */
  name: string;
  /** Additional class names merged onto the group root. */
  className?: string;
};

/**
 * Glitch Radio Group — server component (no pointer JS needed; CSS-only hover/checked glitch).
 *
 * Each option is a visually-hidden native <input type="radio"> positioned absolutely over a
 * custom clip-path button face. The checked and hover states are driven by adjacent-sibling
 * CSS selectors (input:checked + .btn, input:hover + .btn). The glitch overlay animates
 * a shimmy+clip-path scan-line effect on hover and on the active selection.
 */
export function GlitchRadioGroup({
  value,
  onChange,
  options,
  name,
  className = "",
}: GlitchRadioGroupProps) {
  return (
    <div
      className={[styles.group, className].filter(Boolean).join(" ")}
      role="radiogroup"
    >
      {options.map((opt, i) => {
        const id = `${name}-${opt.value}`;
        const tag = opt.tag ?? `r${i + 1}`;
        return (
          <div key={opt.value} className={styles.radioWrapper}>
            {/* Visually-hidden native radio — carries all a11y + form state */}
            <input
              type="radio"
              className={styles.input}
              id={id}
              name={name}
              value={opt.value}
              checked={value === opt.value}
              onChange={() => onChange(opt.value)}
              aria-label={opt.label}
            />
            {/* Custom drawn button face — purely decorative, wired via htmlFor */}
            <label htmlFor={id} className={styles.btn} aria-hidden="true">
              {opt.label}
              {/* Glitch shimmy layer — aria-hidden, decorative scan-line animation */}
              <span className={styles.btnGlitch} aria-hidden="true">
                {opt.label}
              </span>
              {/* Short numeric/textual badge in the corner */}
              <span className={styles.number}>{tag}</span>
            </label>
          </div>
        );
      })}
    </div>
  );
}

Dependencies

  • @bottega/tokens