Proactive Toggle toggle
When it’s disabled, the toggle will stubbornly flip itself back just to mess with you. ( ´థ౪థ)
Inspired by the Useless machine — this little piece of trash is peak maker romance. (´,,•ω•,,)
Why a cat paw? Because a cat paw is the cheekiest, most smackable little hand I could think of. ヾ(◍'౪`◍)ノ゙
Tech Keywords
| Name | Description |
|---|---|
| SVG Morph | Smoothly transitions SVG between different shapes |
| Anime.js | Lightweight JavaScript animation library |
| JS Animation | JavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js |
Usage Examples
Basic Usage
When the toggle is disabled and you try to flip it, the cat paw pops out and switches it right back. (◜௰◝)y
View example source code
<template>
<div class="w-full flex flex-col gap-4 example-wrap">
<base-checkbox
v-model="disabled"
:label="t('disableToggle')"
class="example-ctrl"
/>
<div class="flex flex-1 items-center justify-center">
<toggle-proactive
v-model="value"
:disabled="disabled"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import ToggleProactive from '../toggle-proactive.vue'
const { t } = useI18n()
const disabled = ref(false)
const value = ref(false)
</script>Component Props
Style it however you like — the appearance is fully customizable.
View example source code
<template>
<div class="w-full flex flex-col items-center gap-10 example-wrap p-8">
<toggle-proactive
:model-value="false"
disabled
size="3rem"
fur-color="#DFC57B"
pad-color="#FFF"
/>
<toggle-proactive
:model-value="true"
disabled
size="6rem"
track-inactive-class="bg-red-400"
track-active-class="bg-[#DFDFDF]"
fur-color="#8D6F64"
pad-color="#000"
/>
<toggle-proactive
:model-value="false"
disabled
size="4rem"
track-active-class="bg-[#7DDAEA]"
fur-color="#F3F2F2"
/>
</div>
</template>
<script setup lang="ts">
import ToggleProactive from '../toggle-proactive.vue'
</script>Impossible Requests
The labels are editable, so let’s cutely but politely roast... turn down those clients. ヾ(◍'౪`◍)ノ゙
View example source code
<template>
<div class="example-wrap w-full flex-center p-10">
<div class="flex flex-col gap-4">
<div
v-for="state in stateList"
:key="state.id"
class="flex items-center justify-end gap-5"
>
<div
v-static-text="state.label"
contenteditable="plaintext-only"
spellcheck="false"
class="editable-label text-2xl"
@input="updateLabel(state, $event)"
@keydown.enter.prevent="finishEdit"
/>
<toggle-proactive
ref="toggleRefList"
v-model="state.value"
v-bind="colorData"
size="3.5rem"
:delay="0"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Directive } from 'vue'
import { useCycleList } from '@vueuse/core'
import { pipe, reduce, sample } from 'remeda'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ToggleProactive from '../toggle-proactive.vue'
interface State {
id: string;
value: boolean;
label: string;
}
type Toggle = InstanceType<typeof ToggleProactive>
const { t } = useI18n()
const toggleRefList = ref<Toggle[]>()
const {
state: colorData,
next: nextColor,
} = useCycleList([
{
furColor: '#7DDAEA',
padColor: '#000',
},
{
furColor: '#DFC57B',
padColor: '#000',
},
{
furColor: '#8D6F64',
padColor: '#FFA5A5',
},
{
furColor: '#444',
padColor: '#FFA5A5',
},
])
const stateList = ref<State[]>([
{
id: 'fast',
label: t('wantFast'),
value: false,
},
{
id: 'good',
label: t('wantGood'),
value: false,
},
{
id: 'cheap',
label: t('wantCheap'),
value: false,
},
])
const booleanList = computed(
() => stateList.value.map((state) => state.value),
)
/**
* 只在掛載時寫入初始文字,之後不再跟著 model 更新,
* 避免 Vue 重新渲染洗掉編輯中的游標位置
*/
const vStaticText: Directive<HTMLElement, string> = {
mounted(element, binding) {
element.textContent = binding.value
},
}
function updateLabel(state: State, event: Event) {
const element = event.target as HTMLElement
state.label = element.textContent ?? ''
}
function finishEdit(event: KeyboardEvent) {
const element = event.target as HTMLElement
element.blur()
}
watch(booleanList, (value, oldValue) => {
const allTrue = value.every((v) => v)
if (!allTrue) {
return
}
const targetIndex = pipe(
oldValue,
/** 排除最後一個切換的開關 */
reduce(
(acc: number[], boolValue, i) => boolValue ? [...acc, i] : acc,
[],
),
sample(1),
([i]) => i ?? 0,
)
nextColor()
toggleRefList.value?.[targetIndex]?.toggle()
})
</script>
<style scoped lang="sass">
.editable-label
cursor: text
outline: none
min-width: 3rem
text-align: right
border-bottom: 2px dashed
border-color: color-mix(in srgb, currentColor 0%, transparent)
transition: border-color 0.2s
&:hover,
&:focus
border-color: color-mix(in srgb, currentColor 60%, transparent)
</style>How It Works
Heads up! Σ(ˊДˋ;)
Please don’t set overflow to hidden, or the poor cat paw will get brutally chopped off.
The animation is done with anime.js and an SVG.
There’s a fun little detail: at first, the cat paw is hiding behind the toggle (both the arm and elbow are behind it).
When the toggle animation plays, the arm stays behind the toggle, but the elbow sneaks out in front of it.
How can objects inside an SVG suddenly change their stacking order like that?
Take a guess at how this effect is achieved. (´,,•ω•,,)
I’ll leave it as a tiny mystery for now — if you’re curious, check out the source code or leave a comment with your guess. ( ´ ▽ ` )ノ
Why the track color is not a background-color transition
A strange bug showed up during development: after the cat paw flips a disabled toggle back, the track goes from green to grey — and every now and then it flashes back to green for an instant. Desktop only; mobile was fine. (・∀・😉
Changing one variable at a time narrowed it down:
| Test | Result | Takeaway |
|---|---|---|
| Swap the active color from green to red | Flashes red | The stale pixels are the track's own background |
| Hide the cat paw SVG | No flash | It only flashes while the SVG is on screen |
| Pin the paw's z-index to 0 | Still flashes | Stacking order and occlusion are not to blame |
| Promote the track and the SVG to their own layers | Worse | One more layer means one more blend, which only complicates things |
Set the track to transition-duration: 0s | No flash | The color transition itself is the culprit |
A background-color transition runs on the main thread and repaints every frame. The paw's d is morphing at the same time — also a main-thread repaint. Both land on the same tile, the track's painted result is not updated correctly, and a mid-transition color leaks through.
What the compositor thread is
Browsers split the work of putting a frame on screen across two threads. The main thread runs JavaScript, resolves styles, computes layout and produces paint instructions. The compositor thread only stacks already-painted layers by position, scale and transparency, then hands the result to the GPU. It runs no JavaScript and computes no layout.
| Stage | Where | What it does |
|---|---|---|
| Style | Main thread | Runs JavaScript and resolves the final style of every element |
| Layout | Main thread | Computes positions and sizes |
| Paint | Main thread | Produces a list of paint instructions, not pixels yet |
| Raster | Raster threads and GPU | Turns those instructions into actual pixels |
| Composite | Compositor thread | Stacks the layers by transform and opacity into a frame |
transform, opacity, filter and backdrop-filter never change the pixels inside a layer, only how layers are placed and blended, so the compositor can finish them on its own. That is why scrolling and this kind of animation stay smooth while the main thread is busy with JavaScript. background-color changes the pixels inside the layer, so every frame goes back to the main thread for a repaint.
So the track color became two stacked layers, one inactive and one active, cross-fading through the top layer's opacity. opacity is handled right there on the compositor thread, and each layer holds one fixed color from start to finish, so there is no intermediate value left to get wrong. The flash is gone and the whole animation runs smoother.
Others have hit the same class of bug
- The compositor thread only handles
transform,opacity,filterandbackdrop-filter; everything else falls back to a main-thread repaint, as Blink's animation docs and MDN's animation performance guide both spell out. - Chrome did build compositor acceleration for background-color animations (BackgroundColorPaintWorklet), but the conditions are strict and anything else falls back to the main thread — which is why the same interaction flashes only sometimes, and why desktop and mobile differ.
- Animating an SVG
dis a well-known expensive operation that forces CPU repaints; this classic post on SVG performance argues for transforms over layout attributes. - Cross-fading with
opacityinstead of transitioning a color is a common community trick, used for dark mode transitions too. - Layer promotion is no silver bullet. Chromium has a painting glitch where a transition meets mix-blend-mode that is fixed by adding
translateZ(0)— same family of bug, opposite remedy, so measure it yourself. ( ´ ▽ ` )ノ
Source Code
API
Props
interface Props {
modelValue: boolean;
disabled?: boolean;
/** @default '4rem' */
size?: string;
/** 貓貓手出手前的等待時間,單位 ms
*
* 連續操作會逐次縮短,停手 2 秒後恢復
* @default 1000
*/
delay?: number;
/** @default 'rounded-full' */
trackClass?: string;
/** @default 'bg-[#DFDFDF]' */
trackInactiveClass?: string;
/** @default 'bg-green-500' */
trackActiveClass?: string;
/** @default 'bg-white' */
thumbClass?: string;
/** @default '' */
thumbInactiveClass?: string;
/** @default '' */
thumbActiveClass?: string;
/** @default '#444' */
furColor?: string;
/** @default '#FFA5A5' */
padColor?: string;
}Emits
interface Emits {
'update:modelValue': [value: boolean];
}Methods
interface Expose {
/** 觸發切換動畫 */
toggle: () => Promise<void>;
}