Skip to content

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

NameDescription
SVG MorphSmoothly transitions SVG between different shapes
Anime.jsLightweight JavaScript animation library
JS AnimationJavaScript-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
vue
<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
vue
<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
vue
<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:

TestResultTakeaway
Swap the active color from green to redFlashes redThe stale pixels are the track's own background
Hide the cat paw SVGNo flashIt only flashes while the SVG is on screen
Pin the paw's z-index to 0Still flashesStacking order and occlusion are not to blame
Promote the track and the SVG to their own layersWorseOne more layer means one more blend, which only complicates things
Set the track to transition-duration: 0sNo flashThe 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.

StageWhereWhat it does
StyleMain threadRuns JavaScript and resolves the final style of every element
LayoutMain threadComputes positions and sizes
PaintMain threadProduces a list of paint instructions, not pixels yet
RasterRaster threads and GPUTurns those instructions into actual pixels
CompositeCompositor threadStacks 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.

background-color: 1 layeropacity: 2 layersthe track, one layer onlyactive color, opacity 0 → 1green to grey, all on this layerno second layerinactive color, fixedone layer, repainted every frameonly the top opacity changesbackground-color transitionopacity cross-fadePaint | main threadredone every framePaint | main threaddone onceRasterredone every frameRasterdone onceComposite | compositorevery frameComposite | compositoronly the blend ratio changesrepaints pixels every framere-blends two painted layers

Others have hit the same class of bug

  • The compositor thread only handles transform, opacity, filter and backdrop-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 d is a well-known expensive operation that forces CPU repaints; this classic post on SVG performance argues for transforms over layout attributes.
  • Cross-fading with opacity instead 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>;
}

v0.86.3