Skip to content

Plant Wrapper wrapper

Plants grow along the edges of the wrapped element and sway in the breeze.

Brushing the mouse across them rustles the foliage. ( ´ ▽ ` )ノ

Usage Examples

Plant Types

Each preset is shaped for where it grows.

presetFormGrowth position
grassGrass tuft fanning out denselyBottom edge (prefers both ends)
flowerWildflower on a single upright stemBottom edge (sparse)
daisyDaisy with crisp white petalsBottom edge (sparse)
roseRose with layered deep-pink petalsBottom edge (sparse)
dandelionDandelion whose fluffy seeds drift away on the breezeBottom edge (sparse)
sunflowerSunflower with a tall stem and big golden headBottom edge (sparse)
posyPosy of short stems blooming in clustersBottom edge (clustered)
lavenderLavender with purple flower spikes along the stemBottom edge (sparse)
sproutSprout with a tiny pair of cotyledonsBottom edge (accent)
fernFern with arched fronds unfurling from a curlBottom corners
vineHanging vine in soft drooping strandsTop corners
twigTwig with small branches peeking outTop edge (accent)
pothosPothos, a big-leaved vine crawling horizontallyTop edge
ivyIvy climbing along the frameLeft and right edges

Basic Usage

Pick and mix the presets you like, then trigger regrowth or reset manually.

🌿 內容物
View example source code
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>

Landing Page

Each section's plants start growing only when they enter the viewport.

WRAPPER PLANT

在數位花園裡
慢下來

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

↓ 慢慢往下捲

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

會呼吸的邊框

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

等你路過才綻放

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

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

— 鱈魚
WRAPPER PLANT・用程式碼種出一片花園
View example source code
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>

How It Works

Drawing the Plants

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 Narrative

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 that feels alive.

Wind and Physics

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.

Source Code

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