GitHub43X

ResizeMotion

An animated wrapper that tracks content height changes and handles constrained scrolling.

PreviousNext
      
      <script setup lang="ts">
import { ResizeMotion } from "@/components/resize-motion";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
import { ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "lucide-vue-next";
import { computed, ref } from "vue";
import { DiscordIcon, FigmaIcon, GitHubIcon, GoogleCalendarIcon, IntercomIcon, LinearIcon, NotionIcon, StripeIcon } from "vue3-simple-icons";

const currentStep = ref(0);

const steps = ["Workspace", "Profile", "Tools", "Done"];

const preferences = ["Design reviews", "Planning notes", "Client handoff"];
const imports = [
  {
    id: "linear",
    name: "Linear",
    description: "Issues and project updates",
    icon: LinearIcon,
  },
  {
    id: "github",
    name: "GitHub",
    description: "Repositories and pull requests",
    icon: GitHubIcon,
  },
  {
    id: "notion",
    name: "Notion",
    description: "Product briefs and planning docs",
    icon: NotionIcon,
  },
  {
    id: "figma",
    name: "Figma",
    description: "Design files and comments",
    icon: FigmaIcon,
  },
  {
    id: "discord",
    name: "Discord",
    description: "Community channels and launch updates",
    icon: DiscordIcon,
  },
  {
    id: "google-calendar",
    name: "Google Calendar",
    description: "Milestones and team events",
    icon: GoogleCalendarIcon,
  },
  {
    id: "stripe",
    name: "Stripe",
    description: "Customer and billing updates",
    icon: StripeIcon,
  },
  {
    id: "intercom",
    name: "Intercom",
    description: "Customer conversations",
    icon: IntercomIcon,
  },
];
const selectedImports = ref(["linear", "github", "figma"]);

function isImportSelected(id: string) {
  return selectedImports.value.includes(id);
}

function toggleImport(id: string) {
  if (isImportSelected(id)) {
    selectedImports.value = selectedImports.value.filter((selectedId) => selectedId !== id);
    return;
  }

  selectedImports.value = [...selectedImports.value, id];
}

function goToPreviousStep() {
  currentStep.value = Math.max(currentStep.value - 1, 0);
}

function goToNextStep() {
  currentStep.value = Math.min(currentStep.value + 1, steps.length - 1);
}

const selectedImportTools = computed(() => imports.filter((tool) => selectedImports.value.includes(tool.id)));
</script>

<template>
  <Dialog>
    <DialogTrigger>
      <Button variant="outline">
        Open Dialog
      </Button>
    </DialogTrigger>

    <DialogContent class="overflow-hidden sm:max-w-md py-4 px-0 outline-4 outline-border">
      <ResizeMotion animation="spring" :duration="450" viewport-overflow="hidden">
        <div v-if="currentStep === 0" key="workspace" class="space-y-5 animate-in zoom-in-95 fade-in-0 blur-in-sm duration-300">
          <ul class="flex gap-1.5 px-4">
            <li v-for="(_, index) in steps" :key="index" class="h-1 flex-1 rounded-full bg-muted">
              <span v-if="index <= currentStep" class="block h-full rounded-full bg-foreground" />
            </li>
          </ul>

          <div class="space-y-2 px-4">
            <div class="space-y-1">
              <h3 class="text-2xl font-bold tracking-tighter">
                Create your workspace
              </h3>
              <p class="text-sm text-muted-foreground">
                Start with a calm, focused onboarding step.
              </p>
            </div>
          </div>

          <div class="px-4">
            <div class="relative overflow-hidden">
              <div class="relative space-y-4">
                <div class="flex items-center gap-4">
                  <div class="grid size-11 place-items-center rounded-xl bg-foreground text-sm font-semibold text-background shadow-sm">
                    AS
                  </div>
                  <div>
                    <p class="font-medium leading-none">
                      Acme Studio
                    </p>
                    <p class="mt-1 text-xs text-muted-foreground">
                      Product design workspace
                    </p>
                  </div>
                </div>

                <div class="grid grid-cols-2 gap-2 text-xs">
                  <div class="rounded-lg bg-accent p-3">
                    <p class="text-muted-foreground">
                      Members
                    </p>
                    <p class="mt-1 font-medium">
                      12 teammates
                    </p>
                  </div>
                    <div class="rounded-lg bg-accent p-3">
                    <p class="text-muted-foreground">
                      Setup time
                    </p>
                    <p class="mt-1 font-medium">
                      3 minutes
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div v-else-if="currentStep === 1" key="profile" class="space-y-5 animate-in zoom-in-95 fade-in-0 blur-in-sm duration-300">
          <ul class="flex gap-1.5 px-4">
            <li v-for="(_, index) in steps" :key="index" class="h-1 flex-1 rounded-full bg-muted">
              <span v-if="index <= currentStep" class="block h-full rounded-full bg-foreground" />
            </li>
          </ul>

          <div class="space-y-2 px-4">
            <div class="space-y-1">
              <h3 class="text-2xl font-bold tracking-tighter">
                Tell us how you work
              </h3>
              <p class="text-sm text-muted-foreground">
                A medium step with a few short preferences.
              </p>
            </div>
          </div>

          <div class="space-y-3 px-4">
            <div class="rounded-lg bg-accent p-4">
              <div class="flex items-start justify-between gap-3">
                <div>
                  <p class="font-medium">
                    Design team
                  </p>
                  <p class="mt-1 text-xs text-muted-foreground">
                    Weekly planning with async updates
                  </p>
                </div>
              </div>
            </div>

            <div class="grid gap-2">
              <div v-for="item in preferences" :key="item" class="flex items-center gap-2 rounded-lg border bg-background px-3 py-2.5 shadow-xs">
                <span class="grid size-6 shrink-0 place-items-center rounded-lg">
                  <CheckIcon class="size-3.5" />
                </span>

                <span class="text-xs font-medium">{{ item }}</span>
              </div>
            </div>
          </div>
        </div>

        <div v-else-if="currentStep === 2" key="tools" class="space-y-5 animate-in zoom-in-95 fade-in-0 blur-in-sm duration-300">
          <ul class="flex gap-1.5 px-4">
            <li v-for="(_, index) in steps" :key="index" class="h-1 flex-1 rounded-full bg-muted">
              <span v-if="index <= currentStep" class="block h-full rounded-full bg-foreground" />
            </li>
          </ul>

          <div class="space-y-2 px-4">
            <div class="space-y-1">
              <h3 class="text-2xl font-bold tracking-tighter">
                Connect your tools
              </h3>
              <p class="text-sm text-muted-foreground">
                Choose the integrations you want to sync first.
              </p>
            </div>
          </div>

          <div class="relative space-y-2">
            <div class="pointer-events-none absolute inset-x-0 top-0 z-10 h-5 bg-linear-to-b from-background via-background/80 to-transparent" />
            <div class="max-h-96 space-y-2 overflow-y-auto py-4 px-4 pr-5">
              <Item
                v-for="tool in imports"
                :key="tool.id"
                as="button"
                type="button"
                :data-selected="isImportSelected(tool.id) ? '' : undefined"
                class="w-full cursor-pointer flex-nowrap text-left hover:bg-muted/70 data-selected:border-primary/40 data-selected:bg-primary/5 p-2"
                @click="toggleImport(tool.id)"
              >
                <Checkbox :model-value="isImportSelected(tool.id)" class="size-4 ml-2 [&_svg]:size-3" />
                <ItemMedia variant="icon" class="bg-background">
                  <component :is="tool.icon" class="size-4" />
                </ItemMedia>
                <ItemContent>
                  <ItemTitle class="text-xs">{{ tool.name }}</ItemTitle>
                  <ItemDescription class="text-xs">{{ tool.description }}</ItemDescription>
                </ItemContent>
              </Item>
            </div>
            <div class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-6 bg-linear-to-t from-background via-background/80 to-transparent" />
          </div>
        </div>

        <div v-else key="done" class="space-y-5 animate-in zoom-in-95 fade-in-0 blur-in-sm duration-300">
          <ul class="flex gap-1.5 px-4">
            <li v-for="(_, index) in steps" :key="index" class="h-1 flex-1 rounded-full bg-muted">
              <span v-if="index <= currentStep" class="block h-full rounded-full bg-foreground" />
            </li>
          </ul>

          <div class="flex items-center justify-between gap-4 px-4">
            <div class="space-y-1 text-left">
              <h3 class="text-2xl font-bold tracking-tighter">
                You are ready to go
              </h3>
              <p class="text-sm text-muted-foreground">
                The final step returns to a compact height.
              </p>
            </div>

            <span class="text-4xl pr-2">
              🎉
            </span>
          </div>

          <div class="px-4">
            <Accordion type="single" collapsible default-value="workspace" class="">
              <AccordionItem value="workspace" class="border-border/60">
                <AccordionTrigger class="py-3 text-xs">
                  <span class="text-muted-foreground">Workspace</span>
                  <span class="ml-auto mr-3 font-medium">Acme Studio</span>
                </AccordionTrigger>
                <AccordionContent class="space-y-3 text-xs text-muted-foreground">
                  <div class="flex items-center gap-3 rounded-lg bg-accent p-3">
                    <div class="grid size-9 place-items-center rounded-lg bg-foreground text-xs font-semibold text-background shadow-sm">
                      AS
                    </div>
                    <div>
                      <p class="font-medium text-foreground">
                        Product design workspace
                      </p>
                      <p class="mt-1">
                        Weekly planning, design reviews, and client handoff notes.
                      </p>
                    </div>
                  </div>

                  <div class="grid grid-cols-2 gap-2">
                    <div class="rounded-lg bg-accent p-3">
                      <p>Members</p>
                      <p class="mt-1 font-medium text-foreground">
                        12 teammates
                      </p>
                    </div>
                    <div class="rounded-lg bg-accent p-3">
                      <p>Setup time</p>
                      <p class="mt-1 font-medium text-foreground">
                        3 minutes
                      </p>
                    </div>
                  </div>
                </AccordionContent>
              </AccordionItem>

              <AccordionItem value="imports" class="border-0">
                <AccordionTrigger class="py-3 text-xs">
                  <span class="text-muted-foreground">Imports selected</span>
                  <span class="ml-auto mr-3 font-medium text-foreground">{{ selectedImports.length }} tools</span>
                </AccordionTrigger>
                <AccordionContent class="space-y-2">
                  <Item
                    v-for="tool in selectedImportTools"
                    :key="tool.id"
                    variant="muted"
                    class="bg-accent p-2"
                  >
                    <ItemMedia variant="icon" class="bg-background">
                      <component :is="tool.icon" class="size-4" />
                    </ItemMedia>
                    <ItemContent>
                      <ItemTitle class="text-xs">{{ tool.name }}</ItemTitle>
                      <ItemDescription class="text-xs">{{ tool.description }}</ItemDescription>
                    </ItemContent>
                  </Item>

                  <div v-if="selectedImportTools.length === 0" class="rounded-lg border bg-background/80 p-3 text-xs text-muted-foreground">
                    No tools selected yet.
                  </div>
                </AccordionContent>
              </AccordionItem>
            </Accordion>
          </div>
        </div>
      </ResizeMotion>

      <div class="flex justify-between gap-2 px-4">
        <Button variant="outline" size="sm" :disabled="currentStep === 0" @click="goToPreviousStep">
          <ArrowLeftIcon class="w-4 h-4" />
          Previous
        </Button>
        <Button size="sm" :disabled="currentStep === steps.length - 1" @click="goToNextStep">
          Next
          <ArrowRightIcon class="w-4 h-4" />
        </Button>
      </div>
    </DialogContent>
  </Dialog>
</template>
    

Features

  • Animated height — Observes the slotted content and transitions the wrapper height when it changes
  • Min and max height — Clamp the rendered height with min-height and max-height
  • Scroll support — Keeps overflow inside the internal viewport when content exceeds the maximum height
  • Disabled state — Lock the current height while content changes should not animate
  • Animation control — Use linear, spring, none, or provide a custom CSS transition
  • Lifecycle emits — Listen to resize-start, resize, and resize-end

Installation

Install from the Vuzeno registry with the shadcn-vue CLI:

      
      bunx --bun shadcn-vue@latest add https://vuzeno.com/r/resize-motion.json
    

Anatomy

  • ResizeMotion — Root Primitive that owns the measured, animated height
  • Viewport — Internal scroll container that controls overflow behavior
  • Content — Internal measured wrapper around the default slot