Rube Goldberg Form form
The button won't unlock right away, so let's take it one step at a time. (´-∀-`)
Inspired by the Rube Goldberg machine: a whole contraption built to do something a single click could have done.
Tech Keywords
| Name | Description |
|---|---|
| Anime.js | Lightweight JavaScript animation library |
| SVG | Graphics format usable in the DOM, great for complex shapes and animation; combined with Vue v-bind it enables data-driven effects |
| JS Animation | JavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js |
| Vue Provide/Inject | Vue dependency injection that lets a parent component provide data or methods to all its descendants |
| Element getBoundingClientRect() | Gets an element position and size relative to the viewport |
Usage Examples
Basic Usage
The input, radio group, select, checkbox, and button are machine parts themselves. Put them inside form-rube-goldberg and they get chained together automatically.
The ball leaves the hopper, rolls back and forth along the rail on top of each component, and works its way down to the button.
View example source code
<template>
<div class="relative w-full flex justify-center">
<form-rube-goldberg
ref="machineRef"
class="max-w-[26rem] w-full flex flex-col gap-2"
@submit.prevent="showSubmitted.start()"
>
<form-rube-goldberg-input
v-model="form.nickname"
:label="t('暱稱')"
:placeholder="t('至少 3 個字')"
:min-length="3"
/>
<form-rube-goldberg-radio
v-model="form.frequency"
:label="t('寄送頻率')"
:options="frequencyOptionList"
/>
<form-rube-goldberg-select
v-model="form.topic"
:label="t('感興趣的主題')"
:placeholder="t('選一個主題')"
:options="topicOptionList"
/>
<form-rube-goldberg-checkbox
v-model="form.agreed"
:label="t('我同意收到電子報')"
/>
<form-rube-goldberg-button>
{{ t('訂閱') }}
</form-rube-goldberg-button>
</form-rube-goldberg>
<transition name="opacity">
<div
v-if="isSubmitted"
class="absolute inset-0 z-[40] flex flex-col cursor-pointer items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
@click="reset"
>
<span class="text-xl tracking-wide">
{{ t('訂閱成功!(*´∀`)~♥') }}
</span>
<span class="text-xs">
{{ t('點一下再來一次') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormRubeGoldbergButton from '../form-rube-goldberg-button.vue'
import FormRubeGoldbergCheckbox from '../form-rube-goldberg-checkbox.vue'
import FormRubeGoldbergInput from '../form-rube-goldberg-input.vue'
import FormRubeGoldbergRadio from '../form-rube-goldberg-radio.vue'
import FormRubeGoldbergSelect from '../form-rube-goldberg-select.vue'
import FormRubeGoldberg from '../form-rube-goldberg.vue'
const { t } = useI18n()
const machineRef = useTemplateRef('machineRef')
const frequencyOptionList = computed(() => [
{ label: t('每天'), value: 'daily' },
{ label: t('每週'), value: 'weekly' },
{ label: t('每月'), value: 'monthly' },
])
const topicOptionList = computed(() => [
{ label: t('程式'), value: 'code' },
{ label: t('設計'), value: 'design' },
{ label: t('遊戲'), value: 'game' },
{ label: t('音樂'), value: 'music' },
{ label: t('電影'), value: 'movie' },
{ label: t('攝影'), value: 'photo' },
{ label: t('美食'), value: 'food' },
])
const form = ref({
nickname: '',
frequency: undefined as string | undefined,
topic: undefined as string | undefined,
agreed: false,
})
const isSubmitted = ref(false)
// 送出時機器會跑一段慶祝,等它跑完再蓋上遮罩
const showSubmitted = useTimeoutFn(() => {
isSubmitted.value = true
}, 900, { immediate: false })
function reset() {
isSubmitted.value = false
form.value = {
nickname: '',
frequency: undefined,
topic: undefined,
agreed: false,
}
machineRef.value?.reset()
}
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
</style>How It Works
The frame gives each component a direction in DOM order, the first left to right, the second right to left, and joins them with pipes. There is only one ball, drawn by the frame. When it rolls into a component, the component takes over: it decides how the ball rolls, where it waits, and which animation plays, then hands the ball back at its exit.
The ball carries its speed: it speeds up on the way down, friction pulls it back to a cruising speed on flat rails, and that speed carries over between sections, so it never slows down at a gate that is already open. The ball flung by the check mark follows projectile motion.
Source Code
API
The frame works with five field components:
- form-rube-goldberg: The frame that connects the pipes and rolls the ball. It renders a
<form>itself, so put@submit,action,novalidateand the like on it directly instead of nesting another form inside. - form-rube-goldberg-input: The input. It hangs from the rail on two springs, with a rope tied to the crank of the flap, and a latch holds the tip of the crank. An upright progress bar on its right side fills from the bottom up as you type, and the springs stretch a little. Once the field is valid, the weight passes the tipping point: the input forces the latch aside and drops, pulling the crank so the flap tips over and lets the ball through.
- form-rube-goldberg-radio: The radio group.
- form-rube-goldberg-select: The select. A star wheel sits on the right: slot 0 is a stopper fin, and up to 7 pockets follow, with the options taking turns around them, so there's no limit on how many options you can have. Picking an option turns its pocket to the top; the ball drops in, the wheel turns half a revolution and drops the ball into the exit pipe. Pick the placeholder again and the fin swings back to the top; if the ball had already passed, it gets knocked off and starts over.
- form-rube-goldberg-checkbox: The checkbox.
- form-rube-goldberg-button: The button, always placed last. It stays
disableduntil the portcullis is fully raised, so pressing Enter in an input can't submit the form either.
Attributes such as name, required and autocomplete go to the native control inside each component, while class and style stay on the component's outer element, so the fields work with native form submission.
Set the main colour with color: the ball, the waiting field, the ripples and the button all follow it. For separate light and dark colours, pass light-dark(light, dark).
<form-rube-goldberg color="light-dark(#0ea5e9, #38bdf8)">
...
</form-rube-goldberg>🧩 FormRubeGoldberg
Props
interface Props {
/** 時間倍率,2 表示快一倍,最小 0.1
*
* @default 1
*/
speed?: number;
/** 表單左右留給連接管的寬度,單位 px,建議至少 28
*
* @default 28
*/
gutter?: number;
/** 主色,用在球、正在等待的欄位、漣漪與按鈕。任何 CSS 顏色都行,
* 要分亮暗色可傳 `light-dark(亮色, 暗色)`
*
* @default 'light-dark(#f43f5e, #fb7185)'
*/
color?: string;
}Emits
const emit = defineEmits<{
/** 球抵達按鈕,柵欄拉起 */
unlock: [];
/** 重新上鎖:已解鎖後有欄位變回無效,或呼叫 reset */
lock: [];
/** 球抵達第 index 個元件 */
progress: [index: number];
}>()Slots
defineSlots<{
default?: (params: {
/** 是否已解鎖 */
unlocked: boolean;
}) => unknown;
}>()Methods
interface Expose {
/** 機關全部復原,球回到漏斗重新開始。只重置機關,不會清空欄位的值 */
reset: () => void;
}🧩 FormRubeGoldbergInput
Bind a string with v-model.
Props
interface Props {
label?: string;
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'search';
placeholder?: string;
/** 至少要幾個字才算有效
*
* @default 1
*/
minLength?: number;
/** 要符合的正規表達式 */
pattern?: string;
/** 欄位是否有效。有傳就以它為準,min-length 與 pattern 不再作用 */
ready?: boolean;
}🧩 FormRubeGoldbergRadio
Bind the value of the selected option with v-model.
Props
interface Props {
/** 群組名稱,顯示在選項左邊 */
label?: string;
/** 選項清單,元件建立後請勿增減數量 */
options: Option[];
/** 欄位是否有效。有傳就以它為準,預設是選了任何一個選項就有效 */
ready?: boolean;
}🧩 FormRubeGoldbergSelect
Bind the value of the selected option with v-model; it is undefined when the placeholder is picked.
Props
interface Props {
label?: string;
/** 選項清單,數量不限。轉盤最多 7 個凹槽,選項依序輪流分到各個凹槽 */
options: Option[];
/** 還沒選時顯示的提示,也是一個可以選回去的無效選項 */
placeholder?: string;
/** 欄位是否有效。有傳就以它為準,預設是選了任何一個選項就有效 */
ready?: boolean;
}🧩 FormRubeGoldbergCheckbox
Bind a boolean with v-model.
Props
interface Props {
label?: string;
/** 欄位是否有效。有傳就以它為準,預設是打勾才有效 */
ready?: boolean;
}Slots
defineSlots<{
/** 標籤內容,預設顯示 label */
default?: () => unknown;
}>()🧩 FormRubeGoldbergButton
Props
interface Props {
/** @default 'submit' */
type?: 'submit' | 'button';
}Slots
defineSlots<{
/** 按鈕文字 */
default?: () => unknown;
}>()