Skip to content

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

植栽包裝器 wrapper ​

被包裹的元素邊緣會長出植物,隨風搖曳。

滑鼠經過還會有撥動效果。( ´ ▽ ` )ノ

技術關鍵字 ​

名稱描述
Canvas 2D API基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形
Simplex Noise改良版 Perlin Noise,計算更快且無方向性偏差,常用於程序化生成
彈簧阻尼系統以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋
粒子系統產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果
IntersectionObserver偵測元素是否進入或離開視窗
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊

使用範例 ​

植物種類 ​

每種 preset 都依生長位置設計對應的型態。

preset型態生長位置
grass草叢,扇形茂密展開底部邊緣(偏好兩端)
flower野花,單莖挺立綻放底部邊緣(稀疏)
daisy雛菊,細瓣白菊清爽挺立底部邊緣(稀疏)
rose玫瑰,深粉花瓣層疊綻放底部邊緣(稀疏)
dandelion蒲公英,白絮球隨風飄散底部邊緣(稀疏)
sunflower向日葵,金色大花盤高挺醒目底部邊緣(稀疏)
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="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>

單頁式入口 ​

每個區塊的植物會在進入畫面時才開始生長

WRAPPER PLANT

在數位花園裡
慢下來

植株會沿著邊框生長、隨風搖曳
偶爾清風掃過,花瓣飄落、白絮乘風飄遠

↓ 慢慢往下捲

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

會呼吸的邊框

垂藤從上方披掛、攀藤沿著側邊攀爬,元件包裹之處,皆有生命。

等你路過才綻放

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

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

— 鱈魚
WRAPPER PLANT・用程式碼種出一片花園
查看範例原始碼
vue
<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>

原理 ​

植株繪製 ​

每株植物是一組「參數化骨架」,莖以帶種子的隨機曲率逐段生成,粗到細自然漸縮,帶 S 形微彎。葉片沿莖互生,花朵長在莖頂,薰衣草這類穗狀植物則沿莖上段串生小花點,每株植物的形狀、顏色深淺都不一樣。

水彩質感靠多層半透明疊色完成,葉片與花瓣在印章預渲染階段以 multiply 暈染、 補上邊緣積色與葉脈,模擬水彩乾燥後的特徵; 主畫布則用一般 alpha 合成,植株重疊時顏色自然收斂,不會疊成一團黑。

生長敘事 ​

生長是完整的時間軸,莖尖帶著捲曲破土,像蕨芽一樣逐漸舒展,莖長到哪、葉片就在哪逐片彈開,最後花苞鼓起、花瓣錯落綻放、花心浮現。

每株、每根莖都有隨機延遲,整片植栽會錯落地長出來,更有生命層次感。

風與物理 ​

風分成兩層,持續微風用 Simplex Noise 取樣,搖曳平滑不重複。陣風則是一道由左至右行進的波,掃到哪、哪裡的植株就彎腰。

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

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

若使用者開啟「減少動態效果」(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.93.1