Skip to content
edmi-ui

Shimmer

Animated sweep over streaming or loading text; used for status lines inside a ghost bubble.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Thinking...

Generating your plan

Calling get_prices

Thinking...

Generating your plan

Calling get_prices

Thinking...

Generating your plan

Calling get_prices

Polymorphic (as), uses motion.

Terminal window
npx shadcn@latest add @edmi-ui/ai-shimmer
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-shimmer
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-shimmer
Terminal window
bunx shadcn@latest add @edmi-ui/ai-shimmer

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-shimmer
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-shimmer
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-shimmer
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-shimmer

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-shimmer.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-shimmer.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-shimmer.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-shimmer.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Shimmer } from "@/components/ai/shimmer";
<div className="flex flex-wrap items-center gap-10">
<Shimmer>Thinking...</Shimmer>
<Shimmer as="h2" className="text-2xl font-medium">
Generating your plan
</Shimmer>
<Shimmer duration={1} spread={4}>
Calling get_prices
</Shimmer>
</div>
<script setup lang="ts">
import { Shimmer } from "@/components/ai/shimmer";
</script>
<template>
<div class="flex flex-wrap items-center gap-10">
<Shimmer>Thinking...</Shimmer>
<Shimmer as="h2" class="text-2xl font-medium">Generating your plan</Shimmer>
<Shimmer :duration="1" :spread="4">Calling get_prices</Shimmer>
</div>
</template>
<script lang="ts">
import { Shimmer } from "$lib/components/ai/shimmer";
</script>
<div class="flex flex-wrap items-center gap-10">
<Shimmer>Thinking...</Shimmer>
<Shimmer as="h2" class="text-2xl font-medium">Generating your plan</Shimmer>
<Shimmer duration={1} spread={4}>Calling get_prices</Shimmer>
</div>
registry/components/ai/shimmer.tsx
"use client";
import { cn } from "cn";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating them during render.
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
/** Seconds per sweep. */
duration?: number;
/** Highlight width per character, in px. */
spread?: number;
}
// The sweep is a --foreground highlight over --muted-foreground text; the 0-alpha stops only shape the
// gradient inside the text clip (no surface is transparent, DESIGN §4.16).
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements,
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--foreground),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
data-slot="ai-shimmer"
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
registry/components/ai/shimmer/Shimmer.vue
<script setup lang="ts">
import type { Component, CSSProperties, HTMLAttributes } from "vue"
import { motion } from "motion-v"
import { computed, useSlots } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
export interface TextShimmerProps {
as?: keyof HTMLElementTagNameMap
class?: HTMLAttributes["class"]
/** Seconds per sweep. */
duration?: number
/** Highlight width per character, in px. */
spread?: number
}
const props = withDefaults(defineProps<TextShimmerProps>(), {
as: "p",
duration: 2,
spread: 2,
})
const slots = useSlots()
const textContent = computed(() => {
const defaultSlot = slots.default?.()
if (!defaultSlot || defaultSlot.length === 0)
return ""
return defaultSlot
.map(vnode => (typeof vnode.children === "string" ? vnode.children : ""))
.join("")
})
const dynamicSpread = computed(() => (textContent.value?.length ?? 0) * props.spread)
// The sweep is a --foreground highlight over --muted-foreground text; the 0-alpha stops only shape the
// gradient inside the text clip (no surface is transparent, DESIGN §4.16).
const componentClasses = computed(() => cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--foreground),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
props.class,
))
const componentStyle = computed((): CSSProperties => ({
"--spread": `${dynamicSpread.value}px`,
"backgroundImage": "var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground))",
}))
const MotionComponent = computed(() => (motion[props.as as keyof typeof motion] || motion.p) as Component)
</script>
<template>
<component
:is="MotionComponent"
data-slot="ai-shimmer"
:class="componentClasses"
:style="componentStyle"
:initial="{ backgroundPosition: '100% center' }"
:animate="{ backgroundPosition: '0% center' }"
:transition="{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: 'linear',
}"
>
<slot />
</component>
</template>
registry/components/ai/shimmer/index.ts
export { default as Shimmer } from "./Shimmer.vue";
src/lib/registry/ai/shimmer/index.ts
import Shimmer from "./shimmer.svelte";
export { Shimmer };
src/lib/registry/ai/shimmer/shimmer.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
children,
as = "p",
class: className,
duration = 2,
spread = 2,
...restProps
}: HTMLAttributes<HTMLElement> & {
children: Snippet;
as?: keyof HTMLElementTagNameMap;
/** Seconds per sweep. */
duration?: number;
/** Highlight width per character, in px. */
spread?: number;
} = $props();
// A snippet cannot be measured, so the highlight width follows the rendered text length.
let el = $state<HTMLElement | null>(null);
let length = $state(0);
$effect(() => {
if (!el) return;
const sync = () => (length = el?.textContent?.length ?? 0);
sync();
const observer = new MutationObserver(sync);
observer.observe(el, { characterData: true, childList: true, subtree: true });
return () => observer.disconnect();
});
</script>
<!-- The sweep is a --foreground highlight over --muted-foreground text; the 0-alpha stops only shape the
gradient inside the text clip (no surface is transparent, DESIGN §4.16). -->
<svelte:element
this={as}
bind:this={el}
data-slot="ai-shimmer"
class={cn(
"ai-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--foreground),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
style="--spread: {length * spread}px; --shimmer-duration: {duration}s; background-image: var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground)); background-position: 100% center;"
{...restProps}
>
{@render children()}
</svelte:element>
<style>
@keyframes ai-shimmer-sweep {
from {
background-position: 100% center;
}
to {
background-position: 0% center;
}
}
.ai-shimmer {
animation: ai-shimmer-sweep var(--shimmer-duration, 2s) linear infinite;
}
</style>
Prop Type Default
children string
as ElementType p
duration number (seconds per sweep) 2
spread number (px per character) 2
  • The sweep is a --foreground highlight over --muted-foreground, so it reads in light and dark.