Skip to content

植栽包裝器 wrapper

水彩風植物沿邊框破土生長、隨風搖曳,為內容增添生機。✧⁑。٩(ˊᗜˋ*)و✧⁕。

莖會伸展、葉片逐片展開、花苞鼓起再綻放,開完花還會偶爾飄落花瓣。

不定時有一陣風掃過,植株彎腰再震盪回彈,滑鼠掃過去也會像撥動草叢一樣晃動。

技術關鍵字

名稱 描述
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 自由混搭,並手動觸發重新生長或重置。

🌿 內容物
查看範例原始碼
vue
<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 讓相鄰卡片錯落抽芽,越捲越熱鬧。✿

COD GARDEN

在數位花園裡,慢下來

這裡的植物會沿著邊框破土生長、隨風搖曳。 偶爾一陣風掃過,花瓣就這麼飄了下來。

↓ 慢慢往下捲

每一塊內容,都是一座小花圃

會呼吸的邊框

垂藤從上方披掛、攀藤沿著側邊攀爬, 把無聊的卡片邊框變成有生命的窗景。

等你路過才綻放

捲動到畫面中才開始破土,野花與新芽錯落生長, 每次經過都是不同的風景。

「聽見風的呢喃時,
記得停下來看看花。」

— 鱈魚
COD GARDEN・用程式碼種出一片花園
查看範例原始碼
vue
<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 取樣,搖曳平滑不重複。陣風則是一道由左至右行進的波,掃到哪、哪裡的植株就彎腰。

植株彎曲掛在「彈簧阻尼系統」上,陣風吹過或滑鼠撥動都是對彈簧施力,外力消失後植株會震盪幾下再回正,就像真的撥動草叢。

陣風的強度與間隔可透過 gustStrengthgustIntervalRange 調整,想要狂風大作或微風徐徐都行。

若使用者開啟「減少動態效果」(prefers-reduced-motion),擺動、陣風與粒子會自動停用。 元件捲出畫面時也會自動暫停風場、彈簧與粒子計算,節省性能。

注意!Σ(ˊДˋ;)

請不要將容器的 overflow 設定為 hidden,否則植物會被裁切看不到。

另外 zIndex 預設為 -1(植物在內容下方),若包裝器的祖先元素有背景色, 負 z-index 的畫布會被背景蓋住,這時請把 zIndex 設為正值。

原始碼

API

Props

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;
}

Emits

interface Emits {
  /** 生長動畫開始 */
  growthStart: [];
  /** 生長動畫結束 */
  growthEnd: [];
}

Methods

interface Expose {
  /** 手動觸發生長 */
  grow: () => void;
  /** 重置為未生長狀態 */
  reset: () => void;
}

Slots

interface Slots {
  /** 被包裝的內容物 */
  default?: () => unknown;
}

v0.67.0