Skip to content

Terminal

An implementation of the MacOS terminal. Useful for showcasing a command line interface.

        
            
✔ Preflight checks.
✔ Verifying framework. Found Nuxt.js.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Writing components.json.
✔ Updating tailwind.config.ts
✔ Updating assets/css/main.css
✔ Installing dependencies.
View source on GitHub

Installation

Install the following dependencies

bash
pnpm add motion-v@2

Copy and paste the following code into your project:

vue
<script setup lang="ts">
import type { MotionProps } from "motion-v";
import { motion } from "motion-v";
import { cn } from "@/lib/utils";
interface AnimatedSpanProps extends MotionProps {
  delay?: number;
  className?: string;
}
const props = withDefaults(defineProps<AnimatedSpanProps>(), {
  delay: 0,
});
</script>

<template>
  <motion.div
    :initial="{ opacity: 0, y: -5 }"
    :animate="{
      opacity: 1,
      y: 0,
    }"
    :transition="{
      duration: 0.3,
      delay: props.delay / 1000,
    }"
    :class="cn('grid text-sm font-normal tracking-tight text-foreground', props.className)"
  >
    <slot />
  </motion.div>
</template>
vue
<script setup lang="ts">
import { cn } from "@/lib/utils";
interface TerminalProps {
  className?: string;
}
const props = defineProps<TerminalProps>();
</script>

<template>
  <div
    :class="
      cn(
        'z-0 min-h-[300px] w-full max-w-lg rounded-xl border border-solid border-gray-400 bg-background text-foreground shadow-sm dark:border-neutral-800 dark:shadow-none',
        props.className,
      )
    "
  >
    <div
      class="flex flex-col gap-y-2 border-b border-solid border-gray-400 p-4 dark:border-neutral-800"
    >
      <div class="flex flex-row gap-x-2">
        <div class="h-2 w-2 rounded-full bg-red-500" />
        <div class="h-2 w-2 rounded-full bg-yellow-500" />
        <div class="h-2 w-2 rounded-full bg-green-500" />
      </div>
    </div>
    <pre class="px-4 h-auto">
        <code class="grid gap-y-1">
            <slot />
        </code>
      </pre>
  </div>
</template>
vue
<script setup lang="ts">
import type { MotionProps } from "motion-v";
import { motion } from "motion-v";
import { Comment, defineComponent, onMounted, ref, useSlots, watch, type VNode } from "vue";
import { cn } from "@/lib/utils";

interface TypingAnimationProps extends MotionProps {
  className?: string;
  duration?: number;
  delay?: number;
}

const props = withDefaults(defineProps<TypingAnimationProps>(), {
  duration: 60,
  delay: 0,
});

const MotionComponent = motion.create("span", {
  forwardMotionProps: true,
});

const slots = useSlots();

// Extract text during render so reactive reads inside slots are tracked.
function textContent(nodes: VNode[]): string {
  return nodes
    .map((node) => {
      if (node.type === Comment) return "";
      if (typeof node.children === "string") return node.children;
      return Array.isArray(node.children) ? textContent(node.children as VNode[]) : "";
    })
    .join("");
}

const TypedText = defineComponent({
  props: {
    text: { type: String, required: true },
  },
  setup(content) {
    const displayedText = ref("");
    onMounted(() => {
      watch(
        () => [content.text, props.duration, props.delay] as const,
        ([text, duration, delay], _previous, onCleanup) => {
          displayedText.value = "";
          if (!text) return;
          let interval: ReturnType<typeof setInterval> | undefined;
          const timeout = setTimeout(() => {
            let index = 0;
            interval = setInterval(() => {
              displayedText.value = text.slice(0, ++index);
              if (index >= text.length) clearInterval(interval);
            }, duration);
          }, delay);
          onCleanup(() => {
            clearTimeout(timeout);
            clearInterval(interval);
          });
        },
        { immediate: true },
      );
    });
    return () => displayedText.value;
  },
});
</script>

<template>
  <MotionComponent
    :class="cn('text-sm font-normal tracking-tight text-foreground', props.className)"
  >
    <TypedText :text="textContent(slots.default?.() ?? [])" />
  </MotionComponent>
</template>

Props

Terminal

PropTypeDescriptionDefault
classNamestringThe class for the component.-

AnimatedSpan

PropTypeDescriptionDefault
delaynumberDelay in milliseconds before the animation starts.0
classNamestringThe class for the component.-

TypingAnimation

PropTypeDescriptionDefault
delaynumberDelay in milliseconds before the animation starts.0
classNamestringThe class for the component.-
durationnumberDuration in milliseconds for each character typed.60

The default slot supplies text (including text across fragments). Typing starts empty after mount. Changing the slot text, duration, or delay cancels the previous sequence and restarts after the current delay. Timers are canceled when the component is removed.

Released under the MIT License.