會呼吸的邊框
垂藤從上方披掛、攀藤沿著側邊攀爬, 把無聊的卡片邊框變成有生命的窗景。
水彩風植物沿邊框破土生長、隨風搖曳,為內容增添生機。✧⁑。٩(ˊᗜˋ*)و✧⁕。
莖會伸展、葉片逐片展開、花苞鼓起再綻放,開完花還會偶爾飄落花瓣。
不定時有一陣風掃過,植株彎腰再震盪回彈,滑鼠掃過去也會像撥動草叢一樣晃動。
| 名稱 | 描述 |
|---|---|
| Canvas 2D API | 基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形 |
| Simplex Noise | 改良版 Perlin Noise,計算更快且無方向性偏差,常用於程序化生成 |
| 彈簧阻尼系統 | 以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
| IntersectionObserver | 偵測元素是否進入或離開視窗 |
每種 preset 都依生長位置設計對應的型態,茂密的長角落、小巧的點綴邊緣,不會互相打架。
| preset | 型態 | 生長位置 |
|---|---|---|
grass | 草叢,扇形茂密展開 | 底部邊緣(偏好兩端) |
flower | 野花,單莖挺立綻放 | 底部邊緣(稀疏) |
daisy | 雛菊,細瓣白菊清爽挺立 | 底部邊緣(稀疏) |
posy | 矮花叢,短莖簇擁綻放 | 底部邊緣(成簇) |
lavender | 薰衣草,紫色花穗串生莖上 | 底部邊緣(稀疏) |
sprout | 新芽,一對子葉小巧可愛 | 底部邊緣(點綴) |
fern | 蕨類,拱形羽葉自捲曲舒展 | 底部兩角 |
vine | 垂藤,成束柔軟垂掛 | 頂部兩角 |
twig | 垂枝,小巧枝枒探出頭 | 頂部邊緣(點綴) |
pothos | 黃金葛,大葉蔓藤水平爬行 | 頂部邊緣 |
ivy | 攀藤,貼著邊框攀爬 | 左右側邊 |
勾選想要的植物 preset 自由混搭,並手動觸發重新生長或重置。
<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>一座可以捲動的迷你一頁式網站。第一屏的花園門面長好之後,慢慢往下捲, 每個區塊的植物會在進入畫面時才破土生長(Intersection Observer 觸發), 搭配 delay 讓相鄰卡片錯落抽芽,越捲越熱鬧。✿
在數位花園裡,慢下來
這裡的植物會沿著邊框破土生長、隨風搖曳。 偶爾一陣風掃過,花瓣就這麼飄了下來。
↓ 慢慢往下捲每一塊內容,都是一座小花圃
會呼吸的邊框
垂藤從上方披掛、攀藤沿著側邊攀爬, 把無聊的卡片邊框變成有生命的窗景。
等你路過才綻放
捲動到畫面中才開始破土,野花與新芽錯落生長, 每次經過都是不同的風景。
「聽見風的呢喃時,
記得停下來看看花。」
<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>每株植物是一組「參數化骨架」,莖以帶種子的隨機曲率逐段生成,粗到細自然漸縮,帶 S 形微彎。葉片沿莖互生,花朵長在莖頂,薰衣草這類穗狀植物則沿莖上段串生小花點,每株植物的形狀、顏色深淺都不一樣。
水彩質感靠多層半透明疊色完成,葉片與花瓣在印章預渲染階段以 multiply 暈染、 補上邊緣積色與葉脈,模擬水彩乾燥後的特徵; 主畫布則用一般 alpha 合成,植株重疊時顏色自然收斂,不會疊成一團黑。
生長不是單純線條變長,而是完整的時間軸,莖尖帶著捲曲破土,像蕨芽一樣逐漸舒展,莖長到哪、葉片就在哪逐片彈開,最後花苞鼓起、花瓣錯落綻放、花心浮現。
每株、每根莖都有隨機延遲,整片植栽會錯落地長出來,不會像閱兵一樣整齊。
風分成兩層,持續微風用 Simplex Noise 取樣,搖曳平滑不重複。陣風則是一道由左至右行進的波,掃到哪、哪裡的植株就彎腰。
植株彎曲掛在「彈簧阻尼系統」上,陣風吹過或滑鼠撥動都是對彈簧施力,外力消失後植株會震盪幾下再回正,就像真的撥動草叢。
陣風的強度與間隔可透過 gustStrength、gustIntervalRange 調整,想要狂風大作或微風徐徐都行。
若使用者開啟「減少動態效果」(prefers-reduced-motion),擺動、陣風與粒子會自動停用。 元件捲出畫面時也會自動暫停風場、彈簧與粒子計算,節省性能。
注意!Σ(ˊДˋ;)
請不要將容器的 overflow 設定為 hidden,否則植物會被裁切看不到。
另外 zIndex 預設為 -1(植物在內容下方),若包裝器的祖先元素有背景色, 負 z-index 的畫布會被背景蓋住,這時請把 zIndex 設為正值。
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;
}