ui
Text Only Testimonial
Text testimonial primitive.
Installation
pnpm dlx shadcn@latest add https://registry.circle.health/r/text-only-testimonial.jsonComponent
"use client";
import * as React from "react";
import { cn } from "@/registry/berlin/lib/utils";
type VideoContextValue = {
isMuted: boolean;
isPlaying: boolean;
toggleMute: () => void;
togglePlay: () => void;
};
const VideoContext = React.createContext<VideoContextValue | null>(null);
function useVideoControls() {
const context = React.useContext(VideoContext);
if (!context) {
throw new Error("Video controls must be used within Video.");
}
return context;
}
function parseTextContent(text: unknown): React.ReactNode {
if (!text) return null;
if (typeof text === "string") return text;
if (React.isValidElement(text)) return text;
if (Array.isArray(text)) {
return text
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object" && "children" in item && Array.isArray((item as { children?: unknown[] }).children)) {
return (item as { children: Array<{ text?: string }> }).children.map((child) => child.text ?? "").join("");
}
return "";
})
.join(" ");
}
return String(text);
}
function positionClasses(position: "top left" | "bottom right" | "center" = "bottom right") {
if (position === "top left") return "left-4 top-4";
if (position === "center") return "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2";
return "bottom-4 right-4";
}
function PlayIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="11" stroke="currentColor" strokeWidth="2" />
<path d="m10 8 6 4-6 4V8Z" fill="currentColor" />
</svg>
);
}
function PauseIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="11" stroke="currentColor" strokeWidth="2" />
<path d="M9 8h2v8H9zm4 0h2v8h-2z" fill="currentColor" />
</svg>
);
}
function VolumeIcon({ className, muted }: { className?: string; muted: boolean }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 9h4l5-4v14l-5-4H5V9Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
{muted ? (
<path d="m17 9 4 6m0-6-4 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
) : (
<path d="M18 9c1.333 1 2 2 2 3s-.667 2-2 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
)}
</svg>
);
}
function StarIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="m8 1.5 1.9 3.86 4.26.62-3.08 3 0.72 4.25L8 11.2l-3.8 2 0.72-4.25-3.08-3 4.26-.62L8 1.5Z" />
</svg>
);
}
export interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
config?: {
autoPlay?: boolean;
muted?: boolean;
loop?: boolean;
};
}
type PositionedControlProps = React.PropsWithChildren<{
className?: string;
position?: "top left" | "bottom right" | "center";
}>;
function PlayControl({ className, position = "bottom right" }: PositionedControlProps) {
const { isPlaying, togglePlay } = useVideoControls();
return (
<button
type="button"
aria-label={isPlaying ? "Pause video" : "Play video"}
onClick={togglePlay}
className={cn(
"absolute inline-flex size-12 items-center justify-center rounded-full border border-white/20 bg-black/45 text-white backdrop-blur-sm",
positionClasses(position),
className
)}
>
{isPlaying ? <PauseIcon className="size-6" /> : <PlayIcon className="size-6" />}
</button>
);
}
function MuteControl({ className, position = "top left" }: PositionedControlProps) {
const { isMuted, toggleMute } = useVideoControls();
return (
<button
type="button"
aria-label={isMuted ? "Unmute video" : "Mute video"}
onClick={toggleMute}
className={cn(
"absolute inline-flex size-10 items-center justify-center rounded-full border border-white/20 bg-black/45 text-white backdrop-blur-sm",
positionClasses(position),
className
)}
>
<VolumeIcon muted={isMuted} className="size-5" />
</button>
);
}
function PlayWithText({
className,
position = "center",
children
}: PositionedControlProps) {
const { isPlaying, togglePlay } = useVideoControls();
return (
<button
type="button"
aria-label={isPlaying ? "Pause video" : "Play video"}
onClick={togglePlay}
className={cn(
"absolute inline-flex items-center gap-2 rounded-full border border-white/20 bg-black/55 px-5 py-3 text-sm font-medium text-white backdrop-blur-sm",
positionClasses(position),
className
)}
>
{isPlaying ? <PauseIcon className="size-5" /> : <PlayIcon className="size-5" />}
<span>{children}</span>
</button>
);
}
type VideoComponent = React.FC<VideoProps> & {
Mute: typeof MuteControl;
Play: typeof PlayControl;
PlayWithText: typeof PlayWithText;
};
const VideoBase: React.FC<VideoProps> = ({ src, children, className, config, poster, ...props }) => {
const videoRef = React.useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = React.useState(Boolean(config?.autoPlay));
const [isMuted, setIsMuted] = React.useState(Boolean(config?.muted));
const togglePlay = React.useCallback(() => {
const node = videoRef.current;
if (!node) return;
if (node.paused) {
void node.play();
} else {
node.pause();
}
}, []);
const toggleMute = React.useCallback(() => {
const node = videoRef.current;
if (!node) return;
node.muted = !node.muted;
setIsMuted(node.muted);
}, []);
React.useEffect(() => {
const node = videoRef.current;
if (!node) return;
node.muted = Boolean(config?.muted);
setIsMuted(Boolean(config?.muted));
}, [config?.muted]);
return (
<VideoContext.Provider value={{ isMuted, isPlaying, toggleMute, togglePlay }}>
<div className={cn("relative overflow-hidden rounded-[20px] bg-black", className)}>
<video
ref={videoRef}
className="block h-full w-full object-cover"
poster={poster}
playsInline
preload="metadata"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
autoPlay={config?.autoPlay}
muted={config?.muted}
loop={config?.loop}
{...props}
>
<source src={src} />
</video>
{children ?? (
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto">
<PlayControl />
<MuteControl />
</div>
</div>
)}
</div>
</VideoContext.Provider>
);
};
export const Video = VideoBase as VideoComponent;
Video.Play = PlayControl;
Video.Mute = MuteControl;
Video.PlayWithText = PlayWithText;
export interface MediaSlideProps {
type: "image" | "video";
src: string;
text?: unknown;
poster?: string;
altText?: string;
desktopWidth?: number;
label?: string;
className?: string;
}
export function MediaSlide({
type,
src,
text,
poster,
altText,
label,
className
}: MediaSlideProps) {
const textContent = parseTextContent(text);
return (
<div className={cn("relative h-full min-h-[320px] w-full overflow-hidden rounded-[24px] bg-card", className)}>
{type === "video" ? (
<Video src={src} poster={poster} className="h-full min-h-[320px] rounded-[24px]" />
) : (
<img src={src} alt={altText || "Media"} className="h-full min-h-[320px] w-full object-cover" />
)}
{(label || textContent) ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent p-5 text-white">
{label ? <div className="text-xs font-semibold uppercase tracking-[0.18em] text-white/75">{label}</div> : null}
{textContent ? <div className="mt-2 text-base font-medium leading-6">{textContent}</div> : null}
</div>
) : null}
</div>
);
}
export interface ImageLightboxProps {
src: string;
children: React.ReactNode;
alt?: string;
}
export interface LightboxProps {
image: string;
alt: string;
onClose: () => void;
}
export function Lightbox({ image, alt, onClose }: LightboxProps) {
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKeyDown);
};
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-6" onClick={onClose}>
<button
type="button"
aria-label="Close lightbox"
className="absolute right-6 top-6 inline-flex size-10 items-center justify-center rounded-full border border-white/20 bg-black/40 text-white"
onClick={onClose}
>
<span className="text-xl leading-none">Ć</span>
</button>
<img src={image} alt={alt} className="max-h-full max-w-full rounded-2xl object-contain" onClick={(event) => event.stopPropagation()} />
</div>
);
}
export function ImageWithLightbox({
src,
alt = "",
width,
height
}: {
src: string;
alt?: string;
width: number;
height: number;
}) {
return (
<ImageLightbox src={src} alt={alt}>
<img
src={src}
alt={alt}
width={width}
height={height}
className="max-w-full cursor-zoom-in rounded-2xl object-cover"
/>
</ImageLightbox>
);
}
export function ImageLightbox({ src, children, alt = "" }: ImageLightboxProps) {
const [isOpen, setIsOpen] = React.useState(false);
const child = React.Children.only(children) as React.ReactElement<{
onClick?: React.MouseEventHandler;
}>;
return (
<>
{React.cloneElement(child, {
onClick: (event: React.MouseEvent) => {
child.props.onClick?.(event);
setIsOpen(true);
}
})}
{isOpen ? <Lightbox image={src} alt={alt} onClose={() => setIsOpen(false)} /> : null}
</>
);
}
export interface TextOnlyTestimonialProps {
reviewer: string;
text: string;
stars: number;
source: string;
link: string;
}
export function TextOnlyTestimonial({
reviewer,
text,
stars,
source,
link
}: TextOnlyTestimonialProps) {
return (
<div className="flex h-[228px] w-[345px] flex-col items-start gap-3 bg-card p-4 shadow-[var(--shadow-soft)]">
<p className="text-sm font-medium leading-6 text-foreground">{reviewer}</p>
<p className="line-clamp-4 text-sm leading-6 text-muted-foreground">{text}</p>
<div className="mt-auto flex w-full items-center justify-between gap-2 border-t border-border pt-4">
<div className="flex items-center gap-0.5 text-[var(--Yellow)]">
{Array.from({ length: stars }).map((_, index) => (
<StarIcon key={index} className="size-4" />
))}
</div>
<a href={link} className="text-sm text-foreground/40 transition-colors hover:text-foreground/60">
{source}
</a>
</div>
</div>
);
}
export function TestimonialsCarousel({ children }: { children: React.ReactNode }) {
return (
<div className="w-full overflow-x-auto pb-3">
<div className="flex min-w-full snap-x gap-5 px-6">
{React.Children.map(children, (child, index) => (
<div key={index} className="snap-start">
{child}
</div>
))}
</div>
</div>
);
}
export interface HeroMediaItem {
image?: { url: string; alternativeText?: string };
video?: { url: string };
visualText?: { text: string; name: string };
}
export interface HeroGalleryProps {
slides: HeroMediaItem[];
}
export function HeroGallery({ slides }: HeroGalleryProps) {
const [currentIndex, setCurrentIndex] = React.useState(0);
const safeSlides = slides.length ? slides : [{ image: { url: "", alternativeText: "" } }];
function next() {
setCurrentIndex((index) => (index + 1) % safeSlides.length);
}
function previous() {
setCurrentIndex((index) => (index - 1 + safeSlides.length) % safeSlides.length);
}
function mediaProps(media: HeroMediaItem) {
if (media.video) {
return {
type: "video" as const,
src: media.video.url,
poster: media.image?.url || "",
altText: media.image?.alternativeText || ""
};
}
return {
type: "image" as const,
src: media.image?.url || "",
altText: media.image?.alternativeText || "",
text: media.visualText ? `${media.visualText.text} - ${media.visualText.name}` : undefined
};
}
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-[2fr_1fr_1fr]">
<div className="md:col-span-1">
<MediaSlide {...mediaProps(safeSlides[currentIndex])} />
</div>
<div className="hidden md:block">
<MediaSlide {...mediaProps(safeSlides[(currentIndex + 1) % safeSlides.length])} />
</div>
<div className="hidden md:block">
<MediaSlide {...mediaProps(safeSlides[(currentIndex + 2) % safeSlides.length])} />
</div>
</div>
<div className="flex items-center justify-between">
<button type="button" onClick={previous} className="rounded-full border border-border px-4 py-2 text-sm">
Previous
</button>
<div className="text-sm text-muted-foreground">
{currentIndex + 1} / {safeSlides.length}
</div>
<button type="button" onClick={next} className="rounded-full border border-border px-4 py-2 text-sm">
Next
</button>
</div>
</div>
);
}
Raw registry JSON/r/text-only-testimonial.json