Terminal
An implementation of the MacOS terminal. Useful for showcasing a command line interface.
Credits
Shout out to Magic UI for the inspiration. I actually discovered this component while browsing their website.
Getting Started
Add Components
This consists of four(4) components
Terminal
Terminal
<template>
<div ref="containerRef" :class="styles({ class: props.class })">
<div
class="sticky top-0 left-0 z-10 flex flex-col gap-y-2 border-b border-border bg-background p-4"
>
<div class="flex flex-row gap-x-2">
<div
v-for="(item, i) in buttonColors"
:key="i"
class="size-2 rounded-full"
:class="[item]"
/>
</div>
</div>
<pre class="p-4"><code class="grid! gap-y-1"><slot /></code></pre>
</div>
</template>
<script lang="ts">
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
export type SequenceContextValue = {
/**
* Marks the item at the given index as complete in the sequence.
*/
completeItem: (index: number) => void;
/**
* The currently active index in the sequence.
*/
activeIndex: number;
/**
* Whether the sequence has started.
*/
sequenceStarted: boolean;
};
/**
* Injection key for the sequence context.
*/
export const SequenceKey = Symbol("sequence") as InjectionKey<
ComputedRef<SequenceContextValue | null>
>;
/**
* Injection key for the item index within the sequence.
*/
export const ItemIndexKey = Symbol("itemIndex") as InjectionKey<number | null>;
const styles = tv({
base: "relative z-0 size-full max-w-lg overflow-auto rounded-lg border border-border bg-background",
});
</script>
<script lang="ts" setup>
const props = withDefaults(
defineProps<
PrimitiveProps & {
/**
* Additional classes for the terminal container.
*/
class?: HTMLAttributes["class"];
/**
* Colors for the terminal control buttons.
*/
buttonColors?: string[];
/**
* Whether to enable sequence mode.
*/
sequence?: boolean;
/**
* Whether to start the terminal animation when it comes into view.
*/
startOnView?: boolean;
}
>(),
{
buttonColors: () => ["bg-red-500", "bg-yellow-500", "bg-green-500"],
sequence: true,
startOnView: true,
}
);
const containerRef = useTemplateRef("containerRef");
const activeIndex = ref(0);
const isInView = ref(false);
const slots = useSlots();
// Observe container for startOnView
if (props.startOnView) {
useIntersectionObserver(
containerRef,
([entry], observer) => {
if (entry?.isIntersecting) {
isInView.value = true;
observer.disconnect();
}
},
{ threshold: 0.3 }
);
}
const sequenceStarted = computed(() =>
props.sequence ? !props.startOnView || isInView.value : false
);
const contextValue = computed<SequenceContextValue | null>(() => {
if (!props.sequence) return null;
return {
completeItem: (index: number) => {
if (index === activeIndex.value) {
activeIndex.value++;
}
},
activeIndex: activeIndex.value,
sequenceStarted: sequenceStarted.value,
};
});
// Provide context if sequence mode is enabled
if (props.sequence) {
provide(SequenceKey, contextValue);
// Provide item index for each child
if (slots.default) {
const children = slots.default();
children.forEach((child, index) => {
// We'll provide the index in the child components instead
});
}
}
</script>
TerminalItem
Terminal Item
<template>
<slot />
</template>
<script lang="ts" setup>
import { ItemIndexKey } from "./Terminal.vue";
const props = defineProps<{
index: number;
}>();
provide(ItemIndexKey, props.index);
</script>
AnimatedSpan
Animated Span
<template>
<motion.div
ref="elementRef"
:initial="{ opacity: 0, y: -5 }"
:animate="shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }"
:transition="{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }"
:class="styles({ class: props.class })"
@animation-complete="onAnimationComplete"
>
<slot>{{ text }}</slot>
</motion.div>
</template>
<script lang="ts">
import { motion } from "motion-v";
import type { SequenceContextValue } from "./Terminal.vue";
import type { MotionProps } from "motion-v";
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { ItemIndexKey, SequenceKey } from "./Terminal.vue";
export interface AnimatedSpanProps extends Omit<MotionProps, "as" | "asChild">, PrimitiveProps {
/**
* Additional classes for the animated span.
*/
class?: HTMLAttributes["class"];
/**
* Delay before the animation starts (in milliseconds).
*/
delay?: number;
/**
* Whether to start the animation when the element comes into view.
*/
startOnView?: boolean;
/**
* Optional item index for sequence mode.
*/
itemIndex?: number;
/**
* Text content to display if no slot is provided.
*/
text?: string;
}
const styles = tv({ base: "grid text-sm font-normal tracking-tight" });
</script>
<script lang="ts" setup>
const props = withDefaults(defineProps<AnimatedSpanProps>(), {
delay: 0,
startOnView: false,
as: "div",
});
const elementRef = ref<HTMLDivElement | null>(null);
const isInView = ref(false);
const hasStarted = ref(false);
const sequence = inject<ComputedRef<SequenceContextValue | null>>(SequenceKey);
const providedItemIndex = inject(ItemIndexKey, null);
const itemIndex = props.itemIndex ?? providedItemIndex;
// Observe element for startOnView
if (props.startOnView) {
useIntersectionObserver(
elementRef,
([entry], observer) => {
if (entry?.isIntersecting) {
isInView.value = true;
observer.disconnect();
}
},
{ threshold: 0.3 }
);
}
// Handle sequence-based starting
watch(
() => ({
activeIndex: sequence?.value?.activeIndex,
sequenceStarted: sequence?.value?.sequenceStarted,
}),
({ activeIndex, sequenceStarted }) => {
if (!sequence || itemIndex === null) return;
if (!sequenceStarted) return;
if (hasStarted.value) return;
if (activeIndex === itemIndex) {
hasStarted.value = true;
}
},
{ deep: true }
);
const shouldAnimate = computed(() => {
if (sequence?.value) {
return hasStarted.value;
}
return props.startOnView ? isInView.value : true;
});
const onAnimationComplete = () => {
if (!sequence?.value) return;
if (itemIndex === null) return;
sequence.value.completeItem(itemIndex);
};
</script>
TypingAnimation
Typing Animation
<template>
<component :is="Component" ref="elementRef" :class="styles({ class: props.class })">
{{ displayedText }}
</component>
</template>
<script lang="ts">
import type { SequenceContextValue } from "./Terminal.vue";
import type { MotionProps } from "motion-v";
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { ItemIndexKey, SequenceKey } from "./Terminal.vue";
export interface TypingAnimationProps
extends Omit<MotionProps, "as" | "asChild">, PrimitiveProps {
/**
* Text to be typed out in the animation.
*/
text?: string;
/**
* Additional classes for the typing animation component.
*/
class?: HTMLAttributes["class"];
/**
* Duration of typing for each character (in milliseconds).
*/
duration?: number;
/**
* Delay before the typing starts (in milliseconds).
*/
delay?: number;
/**
* Whether to start the typing animation when the element comes into view.
*/
startOnView?: boolean;
/**
* Optional item index for sequence mode.
*/
itemIndex?: number;
}
const styles = tv({
base: "block text-sm font-normal tracking-tight",
});
</script>
<script lang="ts" setup>
const props = withDefaults(defineProps<TypingAnimationProps>(), {
duration: 60,
delay: 0,
startOnView: true,
as: "div",
});
if (!props.text) {
console.error("[Terminal - TypingAnimation]: Text prop is required");
}
const Component = props.as;
const elementRef = useTemplateRef<HTMLElement | null>("elementRef");
const displayedText = ref("");
const started = ref(false);
const isInView = ref(false);
const sequence = inject<ComputedRef<SequenceContextValue | null>>(SequenceKey);
const providedItemIndex = inject(ItemIndexKey, null);
const itemIndex = props.itemIndex ?? providedItemIndex;
let typingInterval: ReturnType<typeof setInterval> | null = null;
let startTimeout: ReturnType<typeof setTimeout> | null = null;
// Observe element for startOnView
if (props.startOnView) {
useIntersectionObserver(
elementRef,
([entry], observer) => {
if (entry?.isIntersecting) {
isInView.value = true;
observer.disconnect();
}
},
{ threshold: 0.3 }
);
}
// Handle starting the typing animation
watch(
[
() => sequence?.value?.activeIndex,
() => sequence?.value?.sequenceStarted,
() => isInView.value,
],
() => {
if (started.value) return;
// Sequence mode
if (sequence?.value && itemIndex !== null) {
if (!sequence.value.sequenceStarted) return;
if (sequence.value.activeIndex === itemIndex) {
started.value = true;
}
return;
}
// Non-sequence mode
if (!props.startOnView) {
startTimeout = setTimeout(() => {
started.value = true;
}, props.delay);
return;
}
if (!isInView.value) return;
startTimeout = setTimeout(() => {
started.value = true;
}, props.delay);
},
{ immediate: true, deep: true }
);
// Handle typing effect
watch(
() => started.value,
(value) => {
if (!value) return;
let i = 0;
typingInterval = setInterval(() => {
const text = props.text ?? "";
if (i < text.length) {
displayedText.value = text.substring(0, i + 1);
i++;
} else {
if (typingInterval) clearInterval(typingInterval);
// Complete sequence item
if (sequence?.value && itemIndex !== null) {
sequence.value.completeItem(itemIndex);
}
}
}, props.duration);
}
);
onUnmounted(() => {
if (startTimeout) clearTimeout(startTimeout);
if (typingInterval) clearInterval(typingInterval);
});
</script>
Usage
✔ Which Nuxt version are you using? > Nuxt 4✔ Which theme do you want to start with? > Zinc✔ Where is your tailwind.css file located? ... app/assets/css/tailwind.css✔ Where is your tailwind.config file located? ... tailwind.config.js✔ Where should your components be stored? ... app/components/Ui✔ Where should your composables be stored? ... app/composables✔ Where should your plugins be stored? ... app/plugins✔ Where should your utils be stored? ... app/utils✔ Should we just replace component files if they already exist? ... yes✔ Would you like to use the default filename when adding components? ... yes✔ Which package manager do you use? > NPM