會呼吸的邊框
垂藤從上方披掛、攀藤沿著側邊攀爬,元件包裹之處,皆有生命。
Plants grow along the edges of the wrapped element and sway in the breeze.
Brushing the mouse across them rustles the foliage. ( ´ ▽ ` )ノ
Each preset is shaped for where it grows.
| preset | Form | Growth position |
|---|---|---|
grass | Grass tuft fanning out densely | Bottom edge (prefers both ends) |
flower | Wildflower on a single upright stem | Bottom edge (sparse) |
daisy | Daisy with crisp white petals | Bottom edge (sparse) |
rose | Rose with layered deep-pink petals | Bottom edge (sparse) |
dandelion | Dandelion whose fluffy seeds drift away on the breeze | Bottom edge (sparse) |
sunflower | Sunflower with a tall stem and big golden head | Bottom edge (sparse) |
posy | Posy of short stems blooming in clusters | Bottom edge (clustered) |
lavender | Lavender with purple flower spikes along the stem | Bottom edge (sparse) |
sprout | Sprout with a tiny pair of cotyledons | Bottom edge (accent) |
fern | Fern with arched fronds unfurling from a curl | Bottom corners |
vine | Hanging vine in soft drooping strands | Top corners |
twig | Twig with small branches peeking out | Top edge (accent) |
pothos | Pothos, a big-leaved vine crawling horizontally | Top edge |
ivy | Ivy climbing along the frame | Left and right edges |
Pick and mix the presets you like, then trigger regrowth or reset manually.
<template>
<div class="w-full flex flex-col gap-5 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<button
v-for="option in presetOptionList"
:key="option.value"
type="button"
class="border rounded-full px-3.5 py-1 text-sm transition"
:class="option.enabled
? 'border-transparent bg-[#5a8a3c] text-white'
: 'border-gray-300 text-gray-500 hover:border-[#5a8a3c] hover:text-[#5a8a3c]'"
:title="option.positionHint"
@click="option.enabled = !option.enabled"
>
{{ option.label }}
</button>
</div>
<base-btn
label="全選"
class="text-nowrap"
@click="handleSelectAll"
/>
<base-btn
label="清空"
class="text-nowrap"
@click="handleClearSelection"
/>
</div>
<wrapper-plant
ref="plantRef"
immediate
class="w-full border-2 border-gray-300 rounded-lg border-dashed"
:preset-list="selectedPresetList"
>
<div class="min-h-[260px] w-full flex items-center justify-center text-lg">
🌿 內容物
</div>
</wrapper-plant>
<div class="flex flex-wrap gap-4">
<base-btn
label="重新生長"
@click="handleReplay"
/>
<base-btn
label="重置"
@click="handleReset"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { PlantPresetName } from '../plant-presets'
import { computed, reactive, useTemplateRef } from 'vue'
import BaseBtn from '../../base-btn.vue'
import WrapperPlant from '../wrapper-plant.vue'
interface PresetOption {
value: PlantPresetName;
label: string;
/** hover 顯示的生長位置提示 */
positionHint: string;
enabled: boolean;
}
const presetOptionList = reactive<PresetOption[]>([
{ value: 'grass', label: '草叢', positionHint: '底部邊緣,偏好兩端', enabled: true },
{ value: 'flower', label: '野花', positionHint: '底部邊緣,稀疏佇立', enabled: true },
{ value: 'daisy', label: '雛菊', positionHint: '底部邊緣,純白綻放', enabled: true },
{ value: 'rose', label: '玫瑰', positionHint: '底部邊緣,深粉層疊', enabled: true },
{ value: 'dandelion', label: '蒲公英', positionHint: '底部邊緣,白絮隨風飄散', enabled: true },
{ value: 'sunflower', label: '向日葵', positionHint: '底部邊緣,金色大花盤', enabled: true },
{ value: 'posy', label: '矮花叢', positionHint: '底部邊緣,成簇綻放', enabled: true },
{ value: 'lavender', label: '薰衣草', positionHint: '底部邊緣,紫穗輕搖', enabled: true },
{ value: 'sprout', label: '新芽', positionHint: '底部邊緣,小巧點綴', enabled: true },
{ value: 'fern', label: '蕨類', positionHint: '底部兩角,拱形舒展', enabled: true },
{ value: 'vine', label: '垂藤', positionHint: '頂部角落,螺旋垂落', enabled: true },
{ value: 'twig', label: '垂枝', positionHint: '頂部邊緣,探頭點綴', enabled: true },
{ value: 'pothos', label: '黃金葛', positionHint: '頂部邊緣,大葉水平爬行', enabled: true },
{ value: 'ivy', label: '攀藤', positionHint: '左右側邊,貼框攀爬', enabled: true },
])
const selectedPresetList = computed(() =>
presetOptionList
.filter((option) => option.enabled)
.map((option) => option.value),
)
const plantRef = useTemplateRef<InstanceType<typeof WrapperPlant>>('plantRef')
function handleReplay() {
plantRef.value?.reset()
plantRef.value?.grow()
}
function handleReset() {
plantRef.value?.reset()
}
function handleSelectAll() {
presetOptionList.forEach((option) => {
option.enabled = true
})
}
function handleClearSelection() {
presetOptionList.forEach((option) => {
option.enabled = false
})
}
</script>Each section's plants start growing only when they enter the viewport.
在數位花園裡
慢下來
植株會沿著邊框生長、隨風搖曳
偶爾清風掃過,花瓣飄落、白絮乘風飄遠
每一塊內容,都是一座小花圃
會呼吸的邊框
垂藤從上方披掛、攀藤沿著側邊攀爬,元件包裹之處,皆有生命。
等你路過才綻放
捲動到畫面中才開始破土,野花與新芽錯落生長,每次經過都是不同的風景。
「聽見風的呢喃時,
記得停下來看看花。」
<template>
<div
class="h-[560px] w-full flex flex-col gap-10 overflow-x-hidden overflow-y-auto rounded-2xl p-6"
:style="pageBackgroundStyle"
>
<!-- 第一屏:hero 面板 -->
<wrapper-plant
:ref="registerPlantRef"
immediate
class="w-full shrink-0"
:preset-list="['grass', 'flower', 'daisy', 'rose', 'dandelion', 'sunflower', 'posy', 'lavender', 'sprout', 'fern', 'vine', 'twig', 'pothos', 'ivy']"
:z-index="1"
:growth-duration="3600"
>
<section
class="min-h-[472px] flex flex-col items-center justify-center gap-5 rounded-2xl p-8 text-center"
:style="panelStyle"
>
<span class="text-sm text-[#7a8a5a] font-medium tracking-[0.2em]">
WRAPPER PLANT
</span>
<p class="text-4xl text-[#44552f] font-bold">
在數位花園裡<br>慢下來
</p>
<p class="max-w-md text-[#6b705c] leading-relaxed">
植株會沿著邊框生長、隨風搖曳
<br>
偶爾清風掃過,花瓣飄落、白絮乘風飄遠
</p>
<span class="scroll-hint z-20 mt-6 rounded-full bg-white/80 px-4 py-1.5 text-sm text-[#8a9078]">
↓ 慢慢往下捲
</span>
</section>
</wrapper-plant>
<!-- 第二屏:特色卡片,捲入畫面才生長 -->
<section class="flex flex-col gap-8 py-6">
<p class="text-center text-2xl text-[#44552f] font-bold">
每一塊內容,都是一座小花圃
</p>
<div class="grid gap-10 sm:grid-cols-2">
<wrapper-plant
:ref="registerPlantRef"
class="w-full"
:preset-list="['vine', 'twig', 'fern', 'sprout', 'dandelion']"
:z-index="1"
>
<article
class="h-full flex flex-col rounded-xl p-7 pb-10"
:style="panelStyle"
>
<p class="text-lg text-[#44552f] font-bold">
會呼吸的邊框
</p>
<p class="text-sm text-[#6b705c] leading-relaxed">
垂藤從上方披掛、攀藤沿著側邊攀爬,元件包裹之處,皆有生命。
</p>
</article>
</wrapper-plant>
<wrapper-plant
:ref="registerPlantRef"
class="w-full"
:preset-list="['pothos', 'ivy', 'flower', 'daisy', 'sprout', 'grass']"
:delay="400"
:z-index="1"
>
<article
class="h-full flex flex-col rounded-xl p-7 pb-10"
:style="panelStyle"
>
<p class="text-lg text-[#44552f] font-bold">
等你路過才綻放
</p>
<p class="text-sm text-[#6b705c] leading-relaxed">
捲動到畫面中才開始破土,野花與新芽錯落生長,每次經過都是不同的風景。
</p>
</article>
</wrapper-plant>
</div>
</section>
<!-- 第三屏:引言面板 -->
<wrapper-plant
:ref="registerPlantRef"
class="w-full shrink-0"
:preset-list="['sprout', 'flower', 'posy', 'lavender', 'dandelion', 'twig', 'rose', 'sunflower', 'dandelion']"
:delay="200"
:z-index="1"
>
<section
class="min-h-[320px] flex flex-col items-center justify-center gap-4 rounded-2xl px-8 pb-8 text-center"
:style="panelStyle"
>
<p class="max-w-lg text-2xl text-[#44552f] font-medium leading-relaxed">
「聽見風的呢喃時,<br>記得停下來看看花。」
</p>
<span class="text-sm text-[#9aa08a]">— 鱈魚</span>
</section>
</wrapper-plant>
<!-- 頁尾面板 -->
<wrapper-plant
:ref="registerPlantRef"
class="w-full shrink-0"
:preset-list="['sprout', 'ivy', 'grass']"
:z-index="1"
>
<footer
class="flex flex-col items-center gap-5 rounded-2xl px-10 py-12 text-center"
:style="panelStyle"
>
<button
type="button"
class="rounded-full bg-[#5a8a3c] px-8 py-2.5 text-white transition hover:bg-[#4a7430]"
@click="handleReplay"
>
再長一次
</button>
<span class="text-xs text-[#9aa08a] tracking-wider">
WRAPPER PLANT・用程式碼種出一片花園
</span>
</footer>
</wrapper-plant>
</div>
</template>
<script setup lang="ts">
import type { ComponentPublicInstance, CSSProperties } from 'vue'
import { onBeforeUpdate } from 'vue'
import WrapperPlant from '../wrapper-plant.vue'
type WrapperPlantInstance = InstanceType<typeof WrapperPlant>
const pageBackgroundStyle: CSSProperties = {
background: 'linear-gradient(175deg, #f3f1e0 0%, #ecebd8 55%, #e5e6d0 100%)',
}
/** 種植面板:明確的底色與邊框,讓植物清楚地從面板邊緣長出 */
const panelStyle: CSSProperties = {
background: 'linear-gradient(175deg, #fdfcf6 0%, #f7f5e9 100%)',
border: '1px solid rgba(122, 138, 90, 0.35)',
boxShadow: '0 2px 10px rgba(90, 100, 60, 0.08)',
}
let plantRefList: WrapperPlantInstance[] = []
function registerPlantRef(instance: Element | ComponentPublicInstance | null) {
if (instance) {
plantRefList.push(instance as WrapperPlantInstance)
}
}
onBeforeUpdate(() => {
plantRefList = []
})
function handleReplay() {
plantRefList.forEach((plantRef) => {
plantRef.reset()
plantRef.grow()
})
}
</script>
<style scoped>
.scroll-hint {
animation: scroll-hint-float 2.6s ease-in-out infinite;
}
/* 比 animate-bounce 更慢、更柔和的下沉漂浮,呼應往下捲的方向 */
@keyframes scroll-hint-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(7px);
}
}
</style>Every plant is a "parameterized skeleton": stems grow segment by segment with seeded random curvature, tapering naturally from thick to thin with a gentle S-bend. Leaves alternate along the stem and flowers crown the tip, while spiked plants like lavender string tiny blossoms along the upper stem — no two plants share the same shape or shade.
The watercolor feel comes from stacking translucent layers: leaves and petals are blended with multiply during stamp pre-rendering, with edge pooling and veins added to mimic dried watercolor. The main canvas then composites with plain alpha, so overlapping plants converge gracefully instead of piling up into black.
Passerby: "Stacking that many layers every frame — won't it lag? (っ´Ι`)っ"
Codfish: "That's why leaves and petals are pre-rendered into offscreen canvas 'stamps'. During animation it's just drawImage — cheap as can be. ( •̀ ω •́ )✧"
Growth is not a line getting longer but a full timeline: the stem tip breaks ground curled like a fiddlehead and gradually unfurls, leaves pop open wherever the stem has reached, then buds swell, petals bloom in stagger, and the flower center emerges.
Every plant and every stem has its own random delay, so the whole field sprouts in a scattered rhythm that feels alive.
Wind comes in two layers. The constant breeze samples Simplex Noise for smooth, non-repeating swaying. Gusts are traveling waves sweeping from one side to the other — wherever the front passes, plants bow.
Plant bending hangs on a spring-damper system: gusts and mouse strokes apply force to the spring, and once the force fades the plant oscillates a few times before settling — just like brushing through real grass.
Gust strength and interval are tunable via gustStrength and gustIntervalRange, from raging wind to the gentlest breeze.
If the user enables "reduced motion" (prefers-reduced-motion), swaying, gusts, and particles switch off automatically. The component also pauses all wind, spring, and particle computation when scrolled out of view to save performance.
📚 What is IntersectionObserver
Watch out! Σ(ˊДˋ;)
Don't set the container's overflow to hidden, or the plants will be clipped out of sight.
Also, zIndex defaults to -1 (plants behind the content). If any ancestor of the wrapper has a background color, the negative z-index canvas will be covered — set zIndex to a positive value in that case.
interface Props {
/** 預設植物種類(可傳入多個,每個 preset 自帶方位與密度) */
presetList?: PlantPresetName[];
/** 植物 z-index(負值在內容下方,正值在上方) */
zIndex?: number;
/** 進入視窗後延遲觸發生長(ms) */
delay?: number;
/** 是否掛載即觸發(跳過 Intersection Observer) */
immediate?: boolean;
/** 是否啟用持續微風擺動 */
swaying?: boolean;
/** 是否啟用不定時掃過的陣風 */
gusty?: boolean;
/** 陣風強度倍率(1 為預設強度,越大彎得越深) */
gustStrength?: number;
/** 陣風間隔範圍(ms),自上一陣結束起算 */
gustIntervalRange?: ValueRange;
/** 是否啟用滑鼠互動(撥動植株、彈簧回彈) */
interactive?: boolean;
/** 是否顯示環境粒子(飄落花瓣、蒲公英白絮、微光孢子) */
particleVisible?: boolean;
/** 生長動畫時長(ms) */
growthDuration?: number;
/** 容器圓角半徑(px),角落植株會貼合圓弧。未指定時自動偵測內容的 border-radius */
cornerRadius?: number;
}interface Emits {
/** 生長動畫開始 */
growthStart: [];
/** 生長動畫結束 */
growthEnd: [];
}interface Expose {
/** 手動觸發生長 */
grow: () => void;
/** 重置為未生長狀態 */
reset: () => void;
}interface Slots {
/** 被包裝的內容物 */
default?: () => unknown;
}