會呼吸的邊框
垂藤從上方披掛、攀藤沿著側邊攀爬, 把無聊的卡片邊框變成有生命的窗景。
Watercolor plants sprout along the container edges and sway in the breeze, bringing life to your content. (*´∀`)~♥
Stems stretch out, leaves unfold one by one, buds swell and blossom, and petals occasionally drift down after flowering. Every now and then a gust sweeps through — plants bow and spring back with a wobble — and brushing the mouse across them rustles the foliage like running a hand through grass.
Each preset is shaped for where it grows: lush ones claim the corners, tiny ones dot the edges, so they never fight each other.
| 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) |
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="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: '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 handleClearSelection() {
presetOptionList.forEach((option) => {
option.enabled = false
})
}
</script>A scrollable mini one-page site. Once the first-screen garden facade grows in, scroll down slowly — each section's plants sprout only when entering the viewport (triggered by Intersection Observer), and delay staggers neighboring cards so they shoot up one after another. The further you scroll, the livelier it gets. ✿
在數位花園裡,慢下來
這裡的植物會沿著邊框破土生長、隨風搖曳。 偶爾一陣風掃過,花瓣就這麼飄了下來。
↓ 慢慢往下捲每一塊內容,都是一座小花圃
會呼吸的邊框
垂藤從上方披掛、攀藤沿著側邊攀爬, 把無聊的卡片邊框變成有生命的窗景。
等你路過才綻放
捲動到畫面中才開始破土,野花與新芽錯落生長, 每次經過都是不同的風景。
「聽見風的呢喃時,
記得停下來看看花。」
<template>
<div class="w-full border border-gray-200 rounded-xl p-6">
<div
class="h-[560px] w-full flex flex-col gap-10 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', '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 px-8 pb-8 text-center"
:style="panelStyle"
>
<span class="text-sm text-[#7a8a5a] font-medium tracking-[0.4em]">
COD GARDEN
</span>
<p class="text-4xl text-[#44552f] font-bold">
在數位花園裡,慢下來
</p>
<p class="max-w-md text-[#6b705c] leading-relaxed">
這裡的植物會沿著邊框破土生長、隨風搖曳。
偶爾一陣風掃過,花瓣就這麼飄了下來。
</p>
<span class="animate-bounce pt-6 text-[#9aa08a]">
↓ 慢慢往下捲
</span>
</section>
</wrapper-plant>
<!-- 第二屏:特色卡片,捲入畫面才生長 -->
<section class="flex flex-col gap-8 px-2 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']"
: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', 'twig']"
:delay="200"
:z-index="1"
>
<section
class="min-h-[320px] flex flex-col items-center justify-center gap-4 rounded-2xl px-12 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">
COD GARDEN・用程式碼種出一片花園
</span>
</footer>
</wrapper-plant>
</div>
</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>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 instead of marching in formation.
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;
}