diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx
index c1dc882..2e2267f 100644
--- a/src/components/ReqViewer.tsx
+++ b/src/components/ReqViewer.tsx
@@ -5,14 +5,6 @@ import {
Radio,
FileText,
Wifi,
- WifiOff,
- Loader2,
- XCircle,
- ShieldCheck,
- ShieldAlert,
- ShieldX,
- ShieldQuestion,
- Shield,
Filter as FilterIcon,
Download,
Clock,
@@ -20,6 +12,7 @@ import {
Hash,
Search,
Code,
+ Loader2,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
@@ -28,6 +21,7 @@ import { useRelayState } from "@/hooks/useRelayState";
import { FeedEvent } from "./nostr/Feed";
import { KindBadge } from "./KindBadge";
import { UserName } from "./nostr/UserName";
+import { TimelineSkeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
DropdownMenu,
@@ -58,7 +52,6 @@ import {
} from "./ui/accordion";
import { RelayLink } from "./nostr/RelayLink";
import type { NostrFilter } from "@/types/nostr";
-import type { RelayState } from "@/types/relay-state";
import {
formatEventIds,
formatDTags,
@@ -70,6 +63,7 @@ import { sanitizeFilename } from "@/lib/filename-utils";
import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
+import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
const MemoizedFeedEvent = memo(
@@ -77,70 +71,6 @@ const MemoizedFeedEvent = memo(
(prev, next) => prev.event.id === next.event.id,
);
-// Helper functions for relay status icons
-function getConnectionIcon(relay: RelayState | undefined) {
- if (!relay) {
- return {
- icon:
,
- label: "Unknown",
- };
- }
-
- const iconMap = {
- connected: {
- icon:
,
- label: "Connected",
- },
- connecting: {
- icon:
,
- label: "Connecting",
- },
- disconnected: {
- icon:
,
- label: "Disconnected",
- },
- error: {
- icon:
,
- label: "Connection Error",
- },
- };
- return iconMap[relay.connectionState];
-}
-
-function getAuthIcon(relay: RelayState | undefined) {
- if (!relay || relay.authStatus === "none") {
- return null;
- }
-
- const iconMap = {
- authenticated: {
- icon:
,
- label: "Authenticated",
- },
- challenge_received: {
- icon:
,
- label: "Challenge Received",
- },
- authenticating: {
- icon:
,
- label: "Authenticating",
- },
- failed: {
- icon:
,
- label: "Authentication Failed",
- },
- rejected: {
- icon:
,
- label: "Authentication Rejected",
- },
- none: {
- icon:
,
- label: "No Authentication",
- },
- };
- return iconMap[relay.authStatus] || iconMap.none;
-}
-
interface ReqViewerProps {
filter: NostrFilter;
relays?: string[];
@@ -972,8 +902,8 @@ export default function ReqViewer({
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
-
- Loading events...
+
+
)}
diff --git a/src/components/nostr/EmbeddedEvent.tsx b/src/components/nostr/EmbeddedEvent.tsx
index 45387ca..68b8126 100644
--- a/src/components/nostr/EmbeddedEvent.tsx
+++ b/src/components/nostr/EmbeddedEvent.tsx
@@ -1,5 +1,6 @@
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./kinds";
+import { EventCardSkeleton } from "@/components/ui/skeleton";
interface EmbeddedEventProps {
/** Event ID string for regular events */
@@ -70,10 +71,10 @@ export function EmbeddedEvent({
);
}
- // No onOpen handler - just show loading text
+ // No onOpen handler - show skeleton
return (
-
- Loading event...
-
+
+
+
);
}
diff --git a/src/components/nostr/MediaEmbed.tsx b/src/components/nostr/MediaEmbed.tsx
index 7d4a8c7..fd121f1 100644
--- a/src/components/nostr/MediaEmbed.tsx
+++ b/src/components/nostr/MediaEmbed.tsx
@@ -1,7 +1,7 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import Zoom from "react-medium-image-zoom";
import "react-medium-image-zoom/dist/styles.css";
-import { Music, AlertCircle } from "lucide-react";
+import { Music, AlertCircle, Play, RotateCw } from "lucide-react";
import {
isImageURL,
isVideoURL,
@@ -22,6 +22,13 @@ interface MediaEmbedProps {
// Video/Audio-specific
showControls?: boolean; // default: true
onAudioClick?: () => void; // open dialog for audio
+
+ // Loading & Performance
+ aspectRatio?: string; // "16/9", "4/3", "1/1", "auto" - defaults based on media type
+ showPlaceholder?: boolean; // default: true
+ fadeIn?: boolean; // default: true
+ onLoad?: () => void;
+ onError?: (error: Error) => void;
}
const PRESETS = {
@@ -47,11 +54,55 @@ const PRESETS = {
},
} as const;
+/**
+ * Get default aspect ratio based on media type and preset
+ */
+const getDefaultAspectRatio = (
+ mediaType: string,
+ preset: string,
+): string | undefined => {
+ if (preset === "thumbnail") return "1/1";
+ if (mediaType === "video") return "16/9";
+ return undefined; // auto for images
+};
+
+/**
+ * Skeleton placeholder component with shimmer effect
+ */
+const SkeletonPlaceholder = ({
+ aspectRatio,
+ rounded,
+ children,
+}: {
+ aspectRatio?: string;
+ rounded: string;
+ children?: React.ReactNode;
+}) => (
+
+ {children}
+
+);
+
/**
* MediaEmbed component for displaying images, videos, and audio with constraints
* - Images: Use react-medium-image-zoom for inline zoom
* - Videos: Show preview with play button, can trigger dialog
* - Audio: Show audio player
+ *
+ * Features:
+ * - Loading placeholders with skeleton shimmer
+ * - Aspect ratio preservation to prevent layout shift
+ * - Smooth fade-in animations
+ * - Error handling with retry mechanism
+ * - Performance optimized with CSS containment
*/
export function MediaEmbed({
url,
@@ -62,8 +113,16 @@ export function MediaEmbed({
enableZoom = true,
showControls = true,
onAudioClick,
+ aspectRatio,
+ showPlaceholder = true,
+ fadeIn = true,
+ onLoad,
+ onError: onErrorCallback,
}: MediaEmbedProps) {
const [error, setError] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [retryCount, setRetryCount] = useState(0);
// Auto-detect media type if not specified
const mediaType =
@@ -79,72 +138,174 @@ export function MediaEmbed({
const presetStyles = PRESETS[preset];
+ // Determine aspect ratio (user override or default based on type/preset)
+ const effectiveAspectRatio =
+ aspectRatio || getDefaultAspectRatio(mediaType, preset);
+
+ // Reset states when URL changes
+ useEffect(() => {
+ setIsLoading(true);
+ setIsLoaded(false);
+ setError(false);
+ }, [url]);
+
const handleError = () => {
+ setIsLoading(false);
setError(true);
+ if (onErrorCallback) {
+ const error = new Error("Failed to load media");
+ onErrorCallback(error);
+ }
};
- // Error fallback UI
+ const handleLoad = () => {
+ setIsLoading(false);
+ setIsLoaded(true);
+ if (onLoad) onLoad();
+ };
+
+ const handleRetry = () => {
+ setError(false);
+ setIsLoading(true);
+ setRetryCount((prev) => prev + 1);
+ };
+
+ // Error fallback UI with retry
if (error) {
return (
);
}
- // Image rendering with zoom
+ // Image rendering with zoom, placeholder, and fade-in
if (mediaType === "image") {
- const imageElement = (
-

+ {/* Skeleton placeholder */}
+ {showPlaceholder && isLoading && (
+
)}
- style={{
- maxHeight: presetStyles.maxHeight,
- maxWidth: preset === "thumbnail" ? presetStyles.maxWidth : "100%",
- }}
- onError={handleError}
- />
+
+ {/* Image with fade-in */}
+

+
);
return enableZoom ? (
-
{imageElement}
+
{imageContent}
) : (
- imageElement
+ imageContent
);
}
- // Video rendering with inline playback
+ // Video rendering with placeholder and loading state
if (mediaType === "video") {
return (
-
+
+ {/* Skeleton placeholder with play icon */}
+ {showPlaceholder && isLoading && (
+
+
+
+ )}
+
+ {/* Video with fade-in */}
+
+
);
}
diff --git a/src/components/nostr/QuotedEvent.tsx b/src/components/nostr/QuotedEvent.tsx
index c1b036b..660acd9 100644
--- a/src/components/nostr/QuotedEvent.tsx
+++ b/src/components/nostr/QuotedEvent.tsx
@@ -4,6 +4,7 @@ import { KindRenderer } from "./kinds";
import { UserName } from "./UserName";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
+import { CompactQuoteSkeleton } from "@/components/ui/skeleton";
interface QuotedEventProps {
/** Event ID string for regular events */
@@ -65,11 +66,7 @@ export function QuotedEvent({
);
}
- return (
-
- Loading event...
-
- );
+ return
;
}
// For depth 0-1: Show full content inline by default
diff --git a/src/components/nostr/kinds/ChatMessageRenderer.tsx b/src/components/nostr/kinds/ChatMessageRenderer.tsx
index 65e5ac3..52a2ea6 100644
--- a/src/components/nostr/kinds/ChatMessageRenderer.tsx
+++ b/src/components/nostr/kinds/ChatMessageRenderer.tsx
@@ -6,6 +6,7 @@ import { MessageCircle } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { getTagValues } from "@/lib/nostr-utils";
import { isValidHexEventId } from "@/lib/nostr-validation";
+import { InlineReplySkeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 9 - Chat Message (NIP-C7)
@@ -36,7 +37,12 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
return (
- {/* Show quoted parent message if this is a reply */}
+ {/* Show quoted message loading state */}
+ {quotedEventId && !parentEvent && (
+ } />
+ )}
+
+ {/* Show quoted parent message once loaded (only if it's a chat message) */}
{quotedEventId && parentEvent && parentEvent.kind === 9 && (
+ {/* Show parent message loading state */}
+ {pointer && !parentEvent &&
} />}
+
+ {/* Show parent message once loaded */}
{pointer && parentEvent && (
- Loading referenced event...
+
+
)}
diff --git a/src/components/ui/skeleton/CompactQuoteSkeleton.tsx b/src/components/ui/skeleton/CompactQuoteSkeleton.tsx
new file mode 100644
index 0000000..c6dde36
--- /dev/null
+++ b/src/components/ui/skeleton/CompactQuoteSkeleton.tsx
@@ -0,0 +1,32 @@
+import * as React from "react";
+import { Skeleton } from "./Skeleton";
+
+export type CompactQuoteSkeletonProps = React.HTMLAttributes
;
+
+const CompactQuoteSkeleton = React.forwardRef<
+ HTMLDivElement,
+ CompactQuoteSkeletonProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ {/* Author name */}
+
+
+ {/* Quote Content: 2 lines */}
+
+
+
+
+
+ );
+});
+
+CompactQuoteSkeleton.displayName = "CompactQuoteSkeleton";
+
+export { CompactQuoteSkeleton };
diff --git a/src/components/ui/skeleton/EventCardSkeleton.tsx b/src/components/ui/skeleton/EventCardSkeleton.tsx
new file mode 100644
index 0000000..066ea18
--- /dev/null
+++ b/src/components/ui/skeleton/EventCardSkeleton.tsx
@@ -0,0 +1,95 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Skeleton } from "./Skeleton";
+
+const eventCardSkeletonVariants = cva("flex flex-col gap-2", {
+ variants: {
+ variant: {
+ default: "",
+ compact: "",
+ detailed: "",
+ },
+ timeline: {
+ true: "p-3 border-b border-border/50",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ timeline: false,
+ },
+});
+
+export interface EventCardSkeletonProps
+ extends
+ React.HTMLAttributes,
+ VariantProps {
+ showActions?: boolean;
+ isLast?: boolean;
+}
+
+const EventCardSkeleton = React.forwardRef<
+ HTMLDivElement,
+ EventCardSkeletonProps
+>(
+ (
+ {
+ className,
+ variant = "default",
+ timeline = false,
+ showActions = true,
+ isLast = false,
+ ...props
+ },
+ ref,
+ ) => {
+ // Content line count based on variant
+ const contentLines =
+ variant === "compact" ? 2 : variant === "detailed" ? 6 : 4;
+
+ return (
+
+ {/* Header: Name + Timestamp (matches BaseEventContainer) */}
+
+
+
+
+
+ {/* Menu placeholder */}
+
+
+
+ {/* Content */}
+
+ {Array.from({ length: contentLines }).map((_, i) => (
+
+ ))}
+
+
+ {/* Footer: Action buttons */}
+ {showActions && (
+
+
+
+
+
+ )}
+
+ );
+ },
+);
+
+EventCardSkeleton.displayName = "EventCardSkeleton";
+
+export { EventCardSkeleton, eventCardSkeletonVariants };
diff --git a/src/components/ui/skeleton/EventDetailSkeleton.tsx b/src/components/ui/skeleton/EventDetailSkeleton.tsx
new file mode 100644
index 0000000..904fca0
--- /dev/null
+++ b/src/components/ui/skeleton/EventDetailSkeleton.tsx
@@ -0,0 +1,54 @@
+import * as React from "react";
+import { Skeleton } from "./Skeleton";
+
+export type EventDetailSkeletonProps = React.HTMLAttributes;
+
+const EventDetailSkeleton = React.forwardRef<
+ HTMLDivElement,
+ EventDetailSkeletonProps
+>(({ className, ...props }, ref) => {
+ return (
+
+
+ {/* Header: Name + timestamp (matches BaseEventContainer) */}
+
+
+
+
+
+ {/* Event Content: 5 lines */}
+
+
+
+
+
+
+
+
+ {/* Metadata Section */}
+
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ );
+});
+
+EventDetailSkeleton.displayName = "EventDetailSkeleton";
+
+export { EventDetailSkeleton };
diff --git a/src/components/ui/skeleton/InlineReplySkeleton.tsx b/src/components/ui/skeleton/InlineReplySkeleton.tsx
new file mode 100644
index 0000000..b1a97d8
--- /dev/null
+++ b/src/components/ui/skeleton/InlineReplySkeleton.tsx
@@ -0,0 +1,40 @@
+import * as React from "react";
+import { Skeleton } from "./Skeleton";
+
+export interface InlineReplySkeletonProps {
+ /** Icon to show on the left (Reply, MessageCircle, etc.) */
+ icon: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Inline skeleton for loading parent message previews
+ * Matches the exact structure and spacing of the reply box
+ */
+const InlineReplySkeleton = React.forwardRef<
+ HTMLDivElement,
+ InlineReplySkeletonProps
+>(({ icon, className }, ref) => {
+ return (
+
+ {/* Icon - visible during loading */}
+ {React.isValidElement(icon) ? (
+
{icon}
+ ) : null}
+
+ {/* Content placeholder - matches text line-height */}
+
+
+
+
+ );
+});
+
+InlineReplySkeleton.displayName = "InlineReplySkeleton";
+
+export { InlineReplySkeleton };
diff --git a/src/components/ui/skeleton/ProfileCardSkeleton.tsx b/src/components/ui/skeleton/ProfileCardSkeleton.tsx
new file mode 100644
index 0000000..490cb3e
--- /dev/null
+++ b/src/components/ui/skeleton/ProfileCardSkeleton.tsx
@@ -0,0 +1,74 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Skeleton } from "./Skeleton";
+
+const profileCardSkeletonVariants = cva("flex flex-col gap-4", {
+ variants: {
+ variant: {
+ full: "",
+ compact: "",
+ inline: "flex-row items-baseline gap-2",
+ },
+ },
+ defaultVariants: {
+ variant: "full",
+ },
+});
+
+export interface ProfileCardSkeletonProps
+ extends
+ React.HTMLAttributes,
+ VariantProps {
+ showBio?: boolean;
+ showNip05?: boolean;
+}
+
+const ProfileCardSkeleton = React.forwardRef<
+ HTMLDivElement,
+ ProfileCardSkeletonProps
+>(
+ (
+ { className, variant = "full", showBio = true, showNip05 = true, ...props },
+ ref,
+ ) => {
+ // Name height based on variant (ProfileViewer uses 2xl font)
+ const nameHeight = variant === "full" ? 32 : 16;
+ const nameWidth = variant === "full" ? "16rem" : "8rem";
+
+ return (
+
+ {/* Name */}
+
+
+ {/* NIP-05 (optional, only for full variant) */}
+ {showNip05 && variant === "full" && (
+
+ )}
+
+ {/* Bio (only for full and compact variants) */}
+ {showBio && variant !== "inline" && (
+
+ {variant === "full" && (
+
+ )}
+
+
+ {variant === "full" && (
+
+ )}
+
+ )}
+
+ );
+ },
+);
+
+ProfileCardSkeleton.displayName = "ProfileCardSkeleton";
+
+export { ProfileCardSkeleton, profileCardSkeletonVariants };
diff --git a/src/components/ui/skeleton/Skeleton.tsx b/src/components/ui/skeleton/Skeleton.tsx
new file mode 100644
index 0000000..800bd4f
--- /dev/null
+++ b/src/components/ui/skeleton/Skeleton.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+const skeletonVariants = cva("animate-skeleton-pulse bg-muted rounded", {
+ variants: {
+ variant: {
+ circle: "rounded-full",
+ rectangle: "rounded",
+ text: "rounded h-4",
+ },
+ },
+ defaultVariants: {
+ variant: "rectangle",
+ },
+});
+
+export interface SkeletonProps
+ extends
+ React.HTMLAttributes,
+ VariantProps {
+ width?: number | string;
+ height?: number | string;
+}
+
+const Skeleton = React.forwardRef(
+ ({ className, variant, width, height, style, ...props }, ref) => {
+ const inlineStyle: React.CSSProperties = {
+ ...style,
+ ...(width !== undefined && {
+ width: typeof width === "number" ? `${width}px` : width,
+ }),
+ ...(height !== undefined && {
+ height: typeof height === "number" ? `${height}px` : height,
+ }),
+ };
+
+ return (
+
+ );
+ },
+);
+
+Skeleton.displayName = "Skeleton";
+
+export { Skeleton, skeletonVariants };
diff --git a/src/components/ui/skeleton/TimelineSkeleton.tsx b/src/components/ui/skeleton/TimelineSkeleton.tsx
new file mode 100644
index 0000000..9f2d2e1
--- /dev/null
+++ b/src/components/ui/skeleton/TimelineSkeleton.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import {
+ EventCardSkeleton,
+ type EventCardSkeletonProps,
+} from "./EventCardSkeleton";
+
+export interface TimelineSkeletonProps extends Omit<
+ EventCardSkeletonProps,
+ "className" | "isLast" | "timeline"
+> {
+ count?: number;
+ className?: string;
+}
+
+const TimelineSkeleton = React.forwardRef<
+ HTMLDivElement,
+ TimelineSkeletonProps
+>(
+ (
+ {
+ count = 3,
+ variant = "compact",
+ showActions = false,
+ className,
+ ...props
+ },
+ ref,
+ ) => {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+ },
+);
+
+TimelineSkeleton.displayName = "TimelineSkeleton";
+
+export { TimelineSkeleton };
diff --git a/src/components/ui/skeleton/index.ts b/src/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..e07adbb
--- /dev/null
+++ b/src/components/ui/skeleton/index.ts
@@ -0,0 +1,26 @@
+export { Skeleton, skeletonVariants } from "./Skeleton";
+export type { SkeletonProps } from "./Skeleton";
+
+export {
+ ProfileCardSkeleton,
+ profileCardSkeletonVariants,
+} from "./ProfileCardSkeleton";
+export type { ProfileCardSkeletonProps } from "./ProfileCardSkeleton";
+
+export {
+ EventCardSkeleton,
+ eventCardSkeletonVariants,
+} from "./EventCardSkeleton";
+export type { EventCardSkeletonProps } from "./EventCardSkeleton";
+
+export { TimelineSkeleton } from "./TimelineSkeleton";
+export type { TimelineSkeletonProps } from "./TimelineSkeleton";
+
+export { EventDetailSkeleton } from "./EventDetailSkeleton";
+export type { EventDetailSkeletonProps } from "./EventDetailSkeleton";
+
+export { CompactQuoteSkeleton } from "./CompactQuoteSkeleton";
+export type { CompactQuoteSkeletonProps } from "./CompactQuoteSkeleton";
+
+export { InlineReplySkeleton } from "./InlineReplySkeleton";
+export type { InlineReplySkeletonProps } from "./InlineReplySkeleton";
diff --git a/src/lib/relay-status-utils.tsx b/src/lib/relay-status-utils.tsx
new file mode 100644
index 0000000..df4e2b7
--- /dev/null
+++ b/src/lib/relay-status-utils.tsx
@@ -0,0 +1,82 @@
+import {
+ Wifi,
+ WifiOff,
+ Loader2,
+ XCircle,
+ ShieldCheck,
+ ShieldAlert,
+ ShieldX,
+ ShieldQuestion,
+ Shield,
+} from "lucide-react";
+import type { RelayState } from "@/types/relay-state";
+
+/**
+ * Get connection icon and label for a relay state
+ */
+export function getConnectionIcon(relay: RelayState | undefined) {
+ if (!relay) {
+ return {
+ icon: ,
+ label: "Unknown",
+ };
+ }
+
+ const iconMap = {
+ connected: {
+ icon: ,
+ label: "Connected",
+ },
+ connecting: {
+ icon: ,
+ label: "Connecting",
+ },
+ disconnected: {
+ icon: ,
+ label: "Disconnected",
+ },
+ error: {
+ icon: ,
+ label: "Connection Error",
+ },
+ };
+ return iconMap[relay.connectionState];
+}
+
+/**
+ * Get authentication icon and label for a relay state
+ * Returns null if no authentication is required
+ */
+export function getAuthIcon(relay: RelayState | undefined) {
+ if (!relay || relay.authStatus === "none") {
+ return null;
+ }
+
+ const iconMap = {
+ authenticated: {
+ icon: ,
+ label: "Authenticated",
+ },
+ challenge_received: {
+ icon: ,
+ label: "Challenge Received",
+ },
+ authenticating: {
+ icon: ,
+ label: "Authenticating",
+ },
+ failed: {
+ icon: ,
+ label: "Authentication Failed",
+ },
+ rejected: {
+ icon: ,
+ label: "Authentication Rejected",
+ },
+ none: {
+ icon: ,
+ label: "No Authentication",
+ },
+ };
+ return iconMap[relay.authStatus] || iconMap.none;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index f38162f..8795f69 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -23,11 +23,16 @@ export default {
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' }
+ },
+ 'skeleton-pulse': {
+ '0%, 100%': { opacity: '1' },
+ '50%': { opacity: '0.5' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out'
+ 'accordion-up': 'accordion-up 0.2s ease-out',
+ 'skeleton-pulse': 'skeleton-pulse 1.5s ease-in-out infinite'
},
colors: {
background: 'hsl(var(--background))',