Skip to content

Orbital Radio radio

Grab the ball, drag it and fling it — it will be pulled by the gravity of each option and eventually get captured by the nearest black hole.

You can also click an option directly, and the ball will fly over automatically.

Usage Examples

Basic Usage

Can't decide what to drink? Let physics decide for you (ノ>_<)ノ ⌒*

Bubble Tea
Black Tea
Cocoa
Orange Juice
View example source code
vue
<template>
  <div class="w-full flex flex-col gap-4 example-wrap py-10">
    <div class="flex justify-center">
      <radio-orbital
        v-model="selected"
        :options="options"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import RadioOrbital from '../radio-orbital.vue'

const { t } = useI18n()

const options = computed(() => [
  { label: t('bubbleTea'), value: 'bubble-tea' },
  { label: t('blackTea'), value: 'black-tea' },
  { label: t('cocoa'), value: 'cocoa' },
  { label: t('orangeJuice'), value: 'orange-juice' },
])

const selected = ref('bubble-tea')
</script>

Add Items Freely

Dynamically add options and watch the ball navigate through an ever-growing field of black holes.

咖啡
奶茶
Selected: 咖啡
View example source code
vue
<template>
  <div class="w-full flex flex-col gap-4 example-wrap py-10">
    <div class="flex items-center justify-center gap-2">
      <base-input
        v-model="newItem"
        :placeholder="t('placeholder')"
        @keydown.enter="addItem"
      />
      <base-btn
        :disabled="!newItem.trim()"
        :label="t('add')"
        @click="addItem"
      />
    </div>

    <div class="flex justify-center p-4">
      <radio-orbital
        v-model="selected"
        :options="options"
      />
    </div>

    <div class="text-center text-sm text-gray-500 dark:text-gray-400">
      <span>{{ t('selected') }}{{ selected || t('none') }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import RadioOrbital from '../radio-orbital.vue'

interface RadioOption {
  label: string;
  value: string;
}

const { t } = useI18n()

const options = ref<RadioOption[]>([
  { label: '咖啡', value: '咖啡' },
  { label: '奶茶', value: '奶茶' },
])

const selected = ref('咖啡')
const newItem = ref('')

function addItem() {
  const label = newItem.value.trim()
  if (!label)
    return

  options.value.push({ label, value: label })
  newItem.value = ''
}
</script>

Trivia Final Exam

Don't know the answers? Let physics decide! ੭ ˙ᗜ˙ )੭

🧪 Trivia Final Exam — Pick Answers with Orbits!
1. If you fold an A4 paper 42 times, how thick would it be?
About as thick as a dictionary
About as tall as Taipei 101
From Earth to the Moon
You fold it first, then we'll talk
2. Taipei 101 lights up in a different color each night. Based on what?
The temperature
Day of the week
The boss's mood
Random, you're overthinking it
3. How many cups of bubble tea do Taiwanese people drink per year in total?
Over 1 billion cups
About 500 million
About 300 million
No idea, Cod Lin alone drank 100 million
4. How long is a goldfish's memory, really?
Actually just 3 seconds
Longer than you remember your ex's birthday
About 1 day
Several months
5. What color is a polar bear's skin?
White, obviously
Black
Gets tan in the sun
Changes with mood
6. Taiwan was once known as the Republic of what?
Banana Republic
Bubble Tea Republic
Scooter Republic
Overtime Republic

Codfish: "The options for question 3 could be a bit more rude ( ˘・з・)"

View example source code
vue
<template>
  <div class="example-wrap w-full flex flex-col gap-6 py-10">
    <div class="text-center text-lg font-bold">
      {{ t('title') }}
    </div>

    <div
      v-for="(question, index) in questionList"
      :key="question.id"
      class="flex flex-col gap-3 border border-gray-100 rounded-lg p-4"
    >
      <div class="font-medium">
        {{ index + 1 }}. {{ question.title }}
      </div>

      <div class="flex justify-center">
        <radio-orbital
          v-model="answerMap[question.id]"
          :options="question.optionList"
        />
      </div>
    </div>

    <div class="flex justify-center">
      <base-btn
        :label="t('submit')"
        @click="handleSubmit"
      />
    </div>

    <Teleport to="body">
      <Transition name="overlay">
        <div
          v-if="submitted"
          class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
          @click="handleReset"
        >
          <div
            class="overlay-card flex flex-col items-center gap-4 rounded-2xl bg-white px-10 py-8 shadow-xl dark:bg-gray-700"
            @click.stop
          >
            <div class="overlay-kaomoji text-3xl">
              ✧⁑。٩(ˊᗜˋ*)و✧⁕。
            </div>
            <div class="text-xl font-bold">
              {{ t('result', { score: correctCount, total: questionList.length }) }}
            </div>
            <base-btn
              :label="t('retry')"
              @click="handleReset"
            />
          </div>
        </div>
      </Transition>
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import RadioOrbital from '../radio-orbital.vue'

interface QuestionOption {
  label: string;
  value: string;
}

interface Question {
  id: string;
  title: string;
  optionList: QuestionOption[];
  answer: string;
}

const { t } = useI18n()

const questionList = computed<Question[]>(() => [
  {
    id: 'q1',
    title: t('q1.title'),
    optionList: [
      { label: t('q1.a'), value: 'a' },
      { label: t('q1.b'), value: 'b' },
      { label: t('q1.c'), value: 'c' },
      { label: t('q1.d'), value: 'd' },
    ],
    answer: 'c',
  },
  {
    id: 'q2',
    title: t('q2.title'),
    optionList: [
      { label: t('q2.a'), value: 'a' },
      { label: t('q2.b'), value: 'b' },
      { label: t('q2.c'), value: 'c' },
      { label: t('q2.d'), value: 'd' },
    ],
    answer: 'b',
  },
  {
    id: 'q3',
    title: t('q3.title'),
    optionList: [
      { label: t('q3.a'), value: 'a' },
      { label: t('q3.b'), value: 'b' },
      { label: t('q3.c'), value: 'c' },
      { label: t('q3.d'), value: 'd' },
    ],
    answer: 'a',
  },
  {
    id: 'q4',
    title: t('q4.title'),
    optionList: [
      { label: t('q4.a'), value: 'a' },
      { label: t('q4.b'), value: 'b' },
      { label: t('q4.c'), value: 'c' },
      { label: t('q4.d'), value: 'd' },
    ],
    answer: 'd',
  },
  {
    id: 'q5',
    title: t('q5.title'),
    optionList: [
      { label: t('q5.a'), value: 'a' },
      { label: t('q5.b'), value: 'b' },
      { label: t('q5.c'), value: 'c' },
      { label: t('q5.d'), value: 'd' },
    ],
    answer: 'b',
  },
  {
    id: 'q6',
    title: t('q6.title'),
    optionList: [
      { label: t('q6.a'), value: 'a' },
      { label: t('q6.b'), value: 'b' },
      { label: t('q6.c'), value: 'c' },
      { label: t('q6.d'), value: 'd' },
    ],
    answer: 'a',
  },
])

const answerMap = reactive<Record<string, string>>(
  Object.fromEntries(
    questionList.value.map((question) => [question.id, 'a']),
  ),
)
const submitted = ref(false)

const correctCount = computed(() =>
  questionList.value.filter(
    (question) => answerMap[question.id] === question.answer,
  ).length,
)

function resetAnswerMap() {
  for (const question of questionList.value) {
    answerMap[question.id] = 'a'
  }
}

function handleSubmit() {
  submitted.value = true
}

function handleReset() {
  submitted.value = false
  resetAnswerMap()
}
</script>

How It Works

Each option acts like a black hole in space, exerting gravitational pull on the ball. After being flung, the ball's acceleration is calculated using Newton's law of universal gravitation, while a damping coefficient simulates air resistance to gradually slow it down.

When the ball gets close enough to a black hole and its speed drops below a threshold, it gets captured, triggering the selection event.

Here's how it works step by step:

  1. Pointer Events track the drag trajectory, and a sliding time window computes the velocity at the moment of release.
  2. After release, a physics simulation kicks in — each frame calculates the gravitational acceleration from all black holes.
  3. Damping gradually slows the ball down; hitting the viewport boundary causes a bounce.
  4. When the ball enters capture range with low enough speed, it gets sucked into the black hole and the selection is made.

Gravitational Softening

In real N-body simulations, when two objects get extremely close, the denominator in the gravity formula approaches zero, causing acceleration to skyrocket toward infinity.

The softening parameter adds a small value to the denominator to prevent this numerical explosion. (. ❛ ᴗ ❛.)

More Accurate Simulation

After sharing this component, someone suggested using Runge-Kutta instead of the Euler method for solving the equations.

After looking into it, I learned a lot! ヾ(◍'౪`◍)ノ゙

With the Euler method, the ball would exhibit unnatural bouncing when moving between black holes. After switching to RK4, the trajectory became noticeably smoother — no need to artificially lower gravity or increase damping to mask numerical errors.

Later, I took it a step further and replaced RK4 with the more memory-efficient Low-Storage Runge-Kutta 4.5 (LSRK 4.5).

It has the same precision as RK4 (both 4th order — in "4.5", the 4 is the order and 5 is the number of stages), but with a wider stability region and only one temporary vector per integration step.

Never thought making silly little components could be so educational. Thanks to everyone for the feedback! (*´∀`)~♥

Using identical initial conditions and the same black hole configuration, observe the difference in trajectories.

Euler
Euler Sub-step
RK4
LSRK 4.5

The conclusions are clear:

  • Euler's ball exhibits abnormal acceleration due to numerical errors and oscillates wildly near zero points — the trajectory is extremely unstable.
  • Euler Sub-step splits each frame into 4 sub-steps, reducing local error and producing a noticeably more stable trajectory than plain Euler — but it is still first-order.
  • RK4 maintains a smooth and stable orbit.
  • LSRK 4.5 has the same precision as RK4, so the trajectories are similar, but it is more stable with lower memory usage.

Now let's break down the differences between Runge-Kutta and Euler.

From Euler to Runge-Kutta

At its core, a physics simulation is an ordinary differential equation (ODE) initial value problem: given the ball's position and velocity at a certain moment, derive the next state using the laws of mechanics.

Computers can't handle continuous time, so we slice time into small intervals dt (each frame's duration) and use numerical integration to approximate the real trajectory.

The core equations can be written as:

drdt=v,dvdt=a(r)

Where r is position, v is velocity, and a(r) is the position-dependent acceleration (gravity).

Euler Method (First Order)

The most intuitive approach — use the current slope and step forward directly:

vn+1=vn+a(rn)Δtrn+1=rn+vnΔt

Translated to code:

ts
velocity += acceleration * dt
position += velocity * dt

The Euler method's local truncation error is O(Δt2) and global error is O(Δt) , meaning errors accumulate rapidly unless dt is very small.

Why Euler Fails Here

The gravity formula is:

a=Gr2+ϵ2

As the ball approaches a black hole, r shrinks and acceleration spikes dramatically. Euler only samples once at the starting point, producing an enormous acceleration that gets directly applied to velocity.

It looks as if the ball gains extra acceleration out of nowhere, appearing to be shot to the other side of the black hole.

The result is a cycle of being pulled back, shot out, pulled back, shot out again — repeated bouncing.

This is known as the Slingshot Effect, a classic symptom of numerical instability.

Imagine drawing a sharp curve, but you're only allowed to glance at the direction once at the starting point, then walk a big step with your eyes closed — you'll definitely overshoot the curve. =͟͟͞͞( •̀д•́)

Euler Sub-stepping

The source of Euler's error is intuitive: the larger the dt, the more each step drifts.

The most direct fix is to split each frame's dt into N sub-steps and run one standard Euler integration per sub-step:

Δtsub=ΔtNvn+1=vn+a(rn)Δtsubrn+1=rn+vnΔtsub

Translated to code:

ts
const subStepCount = 4
const subDt = dt / subStepCount

for (let i = 0; i < subStepCount; i++) {
  const { ax, ay } = computeAcceleration(position.x, position.y)
  velocity.x += ax * subDt
  velocity.y += ay * subDt
  position.x += velocity.x * subDt
  position.y += velocity.y * subDt
}

The force field is resampled at each sub-step, so errors shrink significantly near strongly curved gravity wells.

The global error is still O(Δt) , but the constant factor shrinks by N — when N=4 , the error is roughly 14 of the original.

Sub-stepping is not the same as a higher-order method

Even with N sub-steps, Euler's error order remains first-order O(Δt) — only the effective step size shrinks.

RK4 achieves O(Δt4) with the same number of force evaluations (4 per frame) — the accuracy gap is a difference of magnitude, not just a multiplier.

Runge-Kutta 4th Order (RK4)

RK4's strategy goes beyond just looking at the starting point — it takes 4 samples within a single dt, considering how the force field changes across the entire interval.

The steps:

k1: Compute acceleration at the starting point (rn,vn)

k1=a(rn)

k2: Take a half step using k1, compute acceleration at the midpoint

k2=a(rn+vnΔt2)

k3: Take another half step using k2 (a different estimate), compute again

k3=a(rn+(vn+k2Δt2)Δt2)

k4: Take a full step using k3, compute acceleration at the endpoint

k4=a(rn+(vn+k3Δt)Δt)

Finally, the weighted average:

vn+1=vn+k1+2k2+2k3+k46Δtrn+1=rn+v(1)+2v(2)+2v(3)+v(4)6Δt

The weights 1:2:2:1 come from Simpson's rule — sampling once at each endpoint and twice at the midpoint, which can exactly integrate up to cubic polynomials.

RK4's local truncation error is O(Δt5) and global error is O(Δt4) . Compared to Euler's O(Δt) , the precision improvement is several orders of magnitude.

Intuitive Comparison

EulerEuler Sub-stepRK4
AnalogyGlance at the direction once, walk a big step blindSplit the big step into four small ones, look each timeWalk a bit, look around, repeat four times, then average out
Samples per frame1N4
Global errorO(Δt)O(Δt) (constant shrinks by N )O(Δt4)
Sharp turn behaviorEasily overshoots the curveMore stable than Euler, but still first-orderStays close to the curve

The trade-off is computing acceleration 4 times per frame (iterating through all black holes 4 times), but the number of black holes equals the number of options — usually no more than 10 — which is absolutely no problem for modern browsers. ( •̀ ω •́ )✧

Low-Storage Runge-Kutta 4.5 (LSRK 4.5)

LSRK's naming convention is "order.stages", so 4.5 means "4th-order precision, 5 integration stages".

Why 5 stages?

Butcher barriers dictate the minimum number of stages needed for a given order:

Target OrderMinimum Stages Required
33
44 (just enough)
56 (crosses a barrier)

RK4 uses exactly 4 stages to achieve 4th-order precision, but its stability region is relatively limited. Carpenter-Kennedy adds one extra stage to gain a wider stability region, making it less likely to diverge at the same dt.

RK4 is precise, but it needs to keep all four intermediate vectors k1 , k2 , k3 , k4 simultaneously — memory usage is over 4 times the state size.

For large-scale computations (e.g., CFD fluid simulations, weather forecasting), this cost is significant.

The LSRK 2N storage scheme, proposed by Williamson (1980), compresses the role of four k vectors into a single continuously updated temporary vector du .

The update rule at each stage is:

duAidu+Δtf(U)UU+Bidu

Where Ai and Bi are precomputed constants, and f(U) is the derivative of the current state (velocity + acceleration).

This component uses Carpenter-Kennedy 5-stage 4th-order coefficients:

Stage iAiBi
1014329971744779575080441755
25673018057731357537059087516183667771713612068292357
32404267990393201674669523817201463215492090206949498
43550918686646209150117938531345643535374481467310338
51275806237668842570457699227782119143714882151754819

Translated to code:

ts
let duPx = 0
let duPy = 0
let duVx = 0
let duVy = 0

for (let i = 0; i < 5; i++) {
  const { ax, ay } = computeAcceleration(position.x, position.y)

  duPx = A[i] * duPx + dt * velocity.x
  duPy = A[i] * duPy + dt * velocity.y
  duVx = A[i] * duVx + dt * ax
  duVy = A[i] * duVy + dt * ay

  position.x += B[i] * duPx
  position.y += B[i] * duPy
  velocity.x += B[i] * duVx
  velocity.y += B[i] * duVy
}

The key difference from RK4 is immediately obvious — no k1, k2, k3, k4.

Only a single du vector gets overwritten at each stage, keeping memory usage at 2N (just the state itself + one temporary vector).

Final comparison of all four methods:

EulerEuler Sub-stepRK4LSRK (Carpenter-Kennedy)
AnalogyGlance at the direction once, walk a big step blindSplit the big step into four small ones, look each timeWalk a bit, look around, repeat four times, then average outSame as RK4, but only remember the last step — no flipping back through notes
Samples per frame1N45
Global errorO(Δt)O(Δt) (constant shrinks by N )O(Δt4)O(Δt4)
Extra memoryNoneNone4× state size (k1 ~ k4)1× state size (du)
Sharp turn behaviorEasily overshoots the curveMore stable than Euler, but still first-orderStays close to the curveStays close to the curve

For simple toy projects like this, you won't really feel LSRK's memory advantage — but it might come in handy someday for large-scale simulations. (・∀・)9

Source Code

API

Props

interface RadioOption {
  label: string;
  value: string;
}

interface Props {
  /** 目前選取的值 */
  modelValue?: string;
  /** 選項列表 */
  options?: RadioOption[];
  /**
   * 球的直徑(px)
   * @default 12
   */
  ballSize?: number;
  /**
   * 重力強度
   * @default 5_000_000
   */
  gravity?: number;
  /**
   * 阻尼係數(0-1)。數值越小球減速越快,會很快停下來;數值越大球滑行距離越長,減速越慢
   * @default 0.9
   */
  damping?: number;
  /**
   * 捕捉距離(px)。球進入此範圍且速度夠低時會被吸入。數值越大越容易被捕捉;數值越小需要更精確地靠近黑洞
   * @default 14
   */
  captureDistance?: number;
  /**
   * 捕捉速度閾值(px/s)。球速度低於此值才會被捕捉。數值越大球在較快速度時也能被吸入;數值越小球必須幾乎靜止才會被捕捉
   * @default 100
   */
  captureSpeed?: number;
  /**
   * 重力軟化參數,防止球靠近黑洞時加速度爆炸。數值越大重力變化越平滑,球不會突然加速;數值越小球靠近黑洞時會急劇加速
   * @default 12
   */
  softening?: number;
  /**
   * 點選黑洞時球移動過去的動畫時長(ms)
   * @default 350
   */
  moveDuration?: number;
  /**
   * 主色調,用於球顏色與選中時的邊框色。接受任何 CSS 合法顏色值
   * @default '#34c6eb'
   */
  color?: string;
}

Emits

interface Emits {
  'update:modelValue': [value: string];
}

v0.63.0