Skip to content

Brittle Wrapper wrapper

Lately I’ve been having so much fun playing Donkey Kong Bananza! ♪( ◜ω◝و(و

As expected from the dev team behind Super Mario Odyssey—both the gameplay and level design are absolutely amazing.

But for some reason this game has become the second-worst 3D motion-sickness game in my life, so I can’t binge it day and night… it hurts my soul ・゚・(つд`゚)・゚・

The in-game buttons crack apart when you press them, which inspired me to recreate a similar effect as a web component! (・∀・)9

What’s the no. 1 motion-sickness game?

No. 1 is What Remains of Edith Finch. I got so motion-sick I couldn’t even finish the first chapter. (。-`ω´-)

If anyone knows how to prevent or reduce 3D motion sickness, please share your tips with me—eternal gratitude in advance! (´,,•ω•,,)

Usage Examples

Known limitations

The actual effect is rendered by a canvas layered on top of the DOM, so interactive content in the DOM can be blocked. For example: text selection, inserting a cursor, etc.

Basic Usage

Clicking will cause the target to crack; the more you click, the more it shatters. Everything can be broken ᕕ( ゚ ∀。)ᕗ

(Right-click or long press on touch devices to restore.)

Glass
Amorphous solid, prone to brittle failure.
Research has shown that this characteristic is also present in certain human positions or identities, commonly known as "glass heart", with a critical stress level approximately equal to one truth.
In practice, it is advisable to adopt a tempered communication approach: first preheat with "maybe I misunderstood" to reduce thermal stress gradients, then gradually release facts to avoid instantaneous damage.
Note the side effects of responsibility attribution displacement: really becoming your fault.
View example source code
vue
<template>
  <div class="w-full flex flex-col items-center justify-center gap-4 md:flex-row">
    <wrapper-brittle
      :ref="cardRefList.set"
      v-slot="{ restore }"
      class="flex-1"
    >
      <div
        class="cursor-pointer border"
        @contextmenu.prevent="restore()"
      >
        <img
          src="/low/profile-2.webp"
          alt=""
          class="pointer-events-none"
        >
      </div>
    </wrapper-brittle>

    <wrapper-brittle
      :ref="cardRefList.set"
      v-slot="{ restore }"
      class="flex-[2]"
    >
      <div
        class="card flex flex-col cursor-pointer gap-2 border rounded p-4 text-sm"
        @contextmenu.prevent="restore()"
      >
        <div class="text-xl font-bold">
          {{ t('glass') }}
        </div>

        <div>
          {{ t('glassDescription') }}
        </div>

        <div>
          {{ t('glassDescription2') }}
        </div>

        <div>
          {{ t('glassDescription3') }}
        </div>

        <div>
          {{ t('glassDescription4') }}
        </div>
      </div>
    </wrapper-brittle>
  </div>
</template>

<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import { throttle } from 'lodash-es'
import { useData } from 'vitepress'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperBrittle from '../wrapper-brittle.vue'

const data = useData()
const { t } = useI18n()

const cardRefList = useTemplateRefsList<
  InstanceType<typeof WrapperBrittle>
>()

const refreshContent = throttle(() => {
  cardRefList.value?.forEach((cardRef) => {
    cardRef.refresh()
  })
}, 100, {
  leading: true,
})

watch(() => data.isDark.value, async () => {
  refreshContent()
})

/** FIX: 水合會導致初始化異常,目前先用延遲更新解決 */
onMounted(() => {
  setTimeout(() => {
    refreshContent()
  }, 1000)
})
</script>

<style lang="sass">
.card
  background: light-dark(#EEE, #333)
</style>

Form Example

If you hit submit without filling everything in, the button will shatter in your face. ( ´థ౪థ)

Submit
View example source code
vue
<template>
  <div class="relative w-full flex justify-center border border-gray-200 rounded-xl p-6">
    <div class="max-w-[20rem] flex flex-col gap-4">
      <base-input
        v-model="form.username"
        :label="t('帳號 *')"
        class="w-full"
      />

      <base-input
        v-model="form.password"
        type="password"
        :label="t('密碼 *')"
        class="w-full"
      />

      <div class="mt-3 flex justify-center">
        <wrapper-brittle
          ref="cardRef"
          :enabled="disabled"
          :thresh-decrease="0.8"
        >
          <div
            class="submit-btn cursor-pointer select-none border rounded-lg p-4 px-6"
            @click="handleSubmit"
          >
            {{ t('送出表單') }}
          </div>
        </wrapper-brittle>
      </div>
    </div>

    <transition name="opacity">
      <div
        v-if="isSubmitted"
        class="absolute inset-0 z-[40] flex flex-col 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="cursor-pointer text-xs">
          {{ t('點一下再來一次') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { throttle } from 'lodash-es'
import { useData } from 'vitepress'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseInput from '../../base-input.vue'
import WrapperBrittle from '../wrapper-brittle.vue'

const { t } = useI18n()

const data = useData()

const cardRef = useTemplateRef('cardRef')

const refreshContent = throttle(() => {
  cardRef.value?.refresh()
}, 100, {
  leading: true,
})
watch(() => data.isDark.value, async () => {
  refreshContent()
})

const form = ref({
  username: '',
  password: '',
})
const disabled = computed(() => {
  return form.value.username === '' || form.value.password === ''
})
whenever(() => !disabled.value, () => {
  cardRef.value?.restore()
})

const isSubmitted = ref(false)
function handleSubmit() {
  if (disabled.value) {
    return
  }
  isSubmitted.value = true
}

function reset() {
  isSubmitted.value = false

  form.value = {
    username: '',
    password: '',
  }
}
</script>

<style lang="sass" scoped>
.submit-btn
  border-color: light-dark(#222, #EEE)
  background-color: light-dark(#FAFAFA, #111)
  color: light-dark(#111, #EEE)

.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
  opacity: 0 !important
</style>

How It Works

First, the DOM is converted into an image, then split into multiple triangles using the Delaunay algorithm. Next, adjacent triangles are merged into polygon “chunks”, each chunk consisting of several triangles, and the seams between chunks are recorded.

Each triangle is rendered via PixiJS + WebGPU, giving excellent performance. ( •̀ ω •́ )✧

When the user clicks on the canvas, a score is calculated for each seam based on its position relative to the click. Seams with scores above a threshold are “broken”, which causes the chunks to be regrouped.

Each group (fragment) then moves away from the click point based on its center position, creating the cracking effect.

Both the fragment shattering and restoration animations are smoothly handled by Anime.js to enhance the visual experience.

It sounds simple when explained like this, but the actual implementation is seriously complicated… ...(›´ω`‹ )

Source Code

API

Props

interface Props {
  enabled?: boolean;
  /** 單位 px,越小切得越碎 */
  splitStep?: number;
  /** 單位 px,越大碎片越不規則 */
  splitJitter?: number;
  /** 每次點擊各碎片移動量,單位 px */
  moveDistancePerClick?: number;
  /** 崩裂門檻,數值越大越容易裂開,越小則需要點越多下才會裂開
   * @default 0.6
   */
  threshDecrease?: number;
}

Emits

interface Emits {
  count: [value: number];
}

Methods

interface Expose {
  clickCount: ComputedRef<number>;
  /** 復原 */
  restore: (duration?: number) => void;
  /** 強制更新 DOM 圖片 */
  refresh: () => void;
}

Slots

interface Slots {
  default?: (props: {
    restore: (duration?: number) => void;
  }) => unknown;
}

v0.51.1