Radio Button Group
radio-button-group.svelteA self-contained group of radio options with selection state.
Demo
Terminal
Editor
Browser
Usage
001 <script lang="ts">
002 import RadioButtonGroup from '$lib/components/radio-button-group.svelte';
003 </script>
004
005 <RadioButtonGroup
006 defaultValue="terminal"
007 options={[
008 { value: 'terminal', label: 'Terminal' },
009 { value: 'editor', label: 'Editor' },
010 { value: 'browser', label: 'Browser' }
011 ]}
012 />
013
radio-button-group.svelte
001 <script lang="ts" module>
002 import type { HTMLAttributes } from 'svelte/elements';
003 import type { Snippet } from 'svelte';
004
005 export type RadioOption = {
006 value: string;
007 label?: string;
008 };
009
010 /** A group of radio options that manages its own selection state. */
011 export type RadioButtonGroupProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
012 ref?: HTMLDivElement | null;
013 options: RadioOption[];
014 defaultValue?: string;
015 /** Set to control selection externally; omit for uncontrolled. */
016 value?: string;
017 children?: Snippet<[RadioOption]>;
018 };
019 </script>
020
021 <script lang="ts">
022 let {
023 class: className,
024 ref = $bindable(null),
025 options,
026 defaultValue = '',
027 value,
028 children,
029 ...restProps
030 }: RadioButtonGroupProps = $props();
031
032 // svelte-ignore state_referenced_locally
033 let internal = $state(defaultValue);
034 let focusedValue = $state<string | null>(null);
035
036 const current = $derived(value !== undefined ? value : internal);
037
038 const select = (v: string) => {
039 if (value === undefined) internal = v;
040 };
041 </script>
042
043 <div class={`radio-group ${className ?? ''}`} bind:this={ref} {...restProps}>
044 {#each options as option, i (option.value)}
045 {@const selected = current === option.value}
046 <div
047 class="radio {selected ? 'selected' : ''} {focusedValue === option.value ? 'focused' : ''}"
048 >
049 <input
050 type="radio"
051 name="radio-group"
052 value={option.value}
053 checked={selected}
054 class="input"
055 id={`radio-group-${i}`}
056 onchange={() => select(option.value)}
057 onfocus={() => (focusedValue = option.value)}
058 onblur={() => (focusedValue = null)}
059 />
060 <div class="relative">
061 <label class="figure" for={`radio-group-${i}`}>
062 {#if selected}
063 <span class="dot" aria-hidden="true"></span>
064 {/if}
065 </label>
066 </div>
067 <div class="right">
068 {#if children}
069 {@render children(option)}
070 {:else}
071 {option.label ?? option.value}
072 {/if}
073 </div>
074 </div>
075 {/each}
076 </div>
077
078 <style>
079 .radio-group {
080 display: flex;
081 flex-direction: column;
082 }
083
084 .radio {
085 display: flex;
086 align-items: flex-start;
087 position: relative;
088 height: var(--line);
089 }
090
091 .relative {
092 flex-shrink: 0;
093 display: flex;
094 }
095
096 .figure {
097 display: inline-flex;
098 height: var(--line);
099 cursor: pointer;
100 color: var(--text-primary);
101 background: var(--border-muted);
102 width: 3ch;
103 align-items: center;
104 justify-content: center;
105 }
106
107 .selected .figure {
108 background: var(--text-primary);
109 }
110
111 .focused .figure {
112 background: var(--focus-ring);
113 }
114
115 .dot {
116 display: inline-block;
117 width: 1ch;
118 height: 1ch;
119 background: var(--surface-base);
120 transform: rotate(45deg);
121 vertical-align: middle;
122 }
123
124 .right {
125 background: var(--surface-base);
126 padding-left: 1ch;
127 line-height: var(--line);
128 flex: 1;
129 }
130
131 .input {
132 position: absolute;
133 height: 1px;
134 width: 1px;
135 opacity: 0;
136 background: transparent;
137 border: 0;
138 outline: 0;
139 margin: 0;
140 padding: 0;
141 }
142 </style>
143