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.)

View example source code
<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. ( ´థ౪థ)
View example source code
<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;
}