Skip to content

植栽包裝器 wrapper

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

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

技術關鍵字

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

使用範例

植物種類

每種 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 取樣,搖曳平滑不重複。陣風則是一道由左至右行進的波,掃到哪、哪裡的植株就彎腰。

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

陣風的強度與間隔可透過 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