Content Section
Long-form marketing content block in three variants: split copy beside media that stays pinned while paragraphs scroll, a stats layout whose callouts roll their numbers up on view, and a pull quote framed by large typographic quote marks, with paragraphs revealed as they scroll into view.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/blocks/content-section
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useMemo } from "react";
import { AnimatedNumber } from "@/components/motion/animated-number";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ContentStat {
value: number;
label: ReactNode;
/** Short line under the label. */
description?: ReactNode;
prefix?: string;
suffix?: string;
/** Fixed decimal places while rolling and at rest. Default 0. */
decimals?: number;
}
export interface ContentQuoteAuthor {
name: string;
role?: ReactNode;
/** Avatar node (image, illustration). Falls back to initials. */
avatar?: ReactNode;
}
export interface ContentSectionProps {
/**
* "split": copy beside media (media sticks while the copy scrolls on wide
* containers). "stats": copy plus rolling stat callouts. "quote": a pull
* quote with large quote marks and its author.
*/
variant?: "split" | "stats" | "quote";
eyebrow?: ReactNode;
heading?: ReactNode;
/** Body copy; each entry is revealed as it scrolls into view. */
paragraphs?: ReactNode[];
/** Extra content after the paragraphs (CTA, list). */
children?: ReactNode;
/** `variant="split"`: media node (image, mock UI, illustration). */
media?: ReactNode;
mediaPosition?: "left" | "right";
/** `variant="split"`: keep the media pinned while the copy scrolls. Default true. */
stickyMedia?: boolean;
/** `variant="stats"`: up to 3 reads best. */
stats?: ContentStat[];
/** `variant="quote"`: the quote itself, without quote marks. */
quote?: ReactNode;
author?: ContentQuoteAuthor;
/** Number formatting locale. Default "en-US" so server and client agree. */
locale?: string;
className?: string;
}
const REVEAL_DURATION = 0.55;
function Reveal({
children,
delay = 0,
className,
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: REVEAL_DURATION, ease: EASE_OUT, delay }}
className={className}
>
{children}
</motion.div>
);
}
function Header({
eyebrow,
heading,
center = false,
}: {
eyebrow?: ReactNode;
heading?: ReactNode;
center?: boolean;
}) {
if (!eyebrow && !heading) return null;
return (
<Reveal className={cn("flex flex-col gap-3", center && "items-center text-center")}>
{eyebrow ? (
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
{eyebrow}
</p>
) : null}
{heading ? (
<h2 className="text-balance text-2xl font-semibold leading-tight tracking-tight @3xl:text-3xl">
{heading}
</h2>
) : null}
</Reveal>
);
}
function Paragraphs({ paragraphs }: { paragraphs?: ReactNode[] }) {
if (!paragraphs?.length) return null;
return (
<div className="flex flex-col gap-4">
{paragraphs.map((paragraph, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are positional copy.
<Reveal key={index}>
<p className="text-pretty text-[15px] leading-7 text-muted-foreground">{paragraph}</p>
</Reveal>
))}
</div>
);
}
function StatValue({ stat, locale }: { stat: ContentStat; locale: string }) {
const decimals = stat.decimals ?? 0;
const formatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}),
[locale, decimals],
);
return (
<span className="text-3xl font-semibold tracking-tight text-foreground tabular-nums @3xl:text-4xl">
{stat.prefix}
<AnimatedNumber value={stat.value} format={(n) => formatter.format(n)} />
{stat.suffix}
</span>
);
}
function initials(name: string) {
const parts = name.trim().split(/\s+/);
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return ((parts[0]?.[0] ?? "") + last).toUpperCase();
}
/**
* Long-form marketing content block in three variants. "split" sets copy
* beside a media slot that stays pinned while the copy scrolls (on wide
* containers); "stats" pairs copy with stat callouts whose numbers roll up
* when they scroll into view; "quote" is a pull quote framed by large
* typographic quote marks with its author. Paragraphs reveal on scroll.
*/
export function ContentSection({
variant = "split",
eyebrow,
heading,
paragraphs,
children,
media,
mediaPosition = "right",
stickyMedia = true,
stats = [],
quote,
author,
locale = "en-US",
className,
}: ContentSectionProps) {
const reduce = useReducedMotion() ?? false;
if (variant === "quote") {
return (
<section className={cn("@container w-full text-foreground", className)}>
<figure className="relative mx-auto m-0 flex max-w-3xl flex-col items-center gap-6 px-2 py-6 text-center">
{eyebrow ? (
<Reveal>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
{eyebrow}
</p>
</Reveal>
) : null}
<div className="relative px-6 @md:px-12">
{/* Decorative marks: a transform pop on view, nothing else moves. */}
<motion.span
aria-hidden
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, amount: 0.6 }}
transition={reduce ? { duration: 0.3, ease: EASE_OUT } : SPRING_SWAP}
className="pointer-events-none absolute -top-6 left-0 select-none font-serif text-7xl leading-none text-foreground/15 @md:-top-8 @md:text-8xl"
>
“
</motion.span>
<motion.span
aria-hidden
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, amount: 0.6 }}
transition={
reduce ? { duration: 0.3, ease: EASE_OUT } : { ...SPRING_SWAP, delay: 0.12 }
}
className="pointer-events-none absolute right-0 -bottom-14 select-none font-serif text-7xl leading-none text-foreground/15 @md:-bottom-20 @md:text-8xl"
>
”
</motion.span>
<Reveal delay={0.05}>
<blockquote className="m-0">
<p className="text-balance text-xl font-medium leading-snug tracking-tight @md:text-2xl @3xl:text-3xl">
{quote}
</p>
</blockquote>
</Reveal>
</div>
{author ? (
<Reveal delay={0.15}>
<figcaption className="flex items-center gap-3 text-left">
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-full border border-border bg-muted text-xs font-semibold text-foreground">
{author.avatar ?? initials(author.name)}
</span>
<span className="flex flex-col">
<span className="text-sm font-medium text-foreground">{author.name}</span>
{author.role ? (
<span className="text-xs text-muted-foreground">{author.role}</span>
) : null}
</span>
</figcaption>
</Reveal>
) : null}
{children}
</figure>
</section>
);
}
if (variant === "stats") {
return (
<section className={cn("@container w-full text-foreground", className)}>
<div className="flex flex-col gap-8">
<div className="grid gap-6 @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:gap-10">
<Header eyebrow={eyebrow} heading={heading} />
<div className="flex flex-col gap-4">
<Paragraphs paragraphs={paragraphs} />
{children}
</div>
</div>
{stats.length > 0 ? (
<dl className="m-0 grid grid-cols-1 overflow-hidden rounded-2xl border border-border bg-card @lg:grid-cols-3">
{stats.map((stat, index) => (
<Reveal
// biome-ignore lint/suspicious/noArrayIndexKey: stats are positional.
key={index}
delay={index * 0.08}
className={cn(
"flex flex-col gap-1 p-5",
index > 0 && "border-t border-border @lg:border-t-0 @lg:border-l",
)}
>
<dt className="order-2 text-sm font-medium text-foreground">{stat.label}</dt>
<dd className="order-1 m-0">
<StatValue stat={stat} locale={locale} />
</dd>
{stat.description ? (
<dd className="order-3 m-0 text-xs leading-5 text-muted-foreground">
{stat.description}
</dd>
) : null}
</Reveal>
))}
</dl>
) : null}
</div>
</section>
);
}
return (
<section className={cn("@container w-full text-foreground", className)}>
<div className="grid gap-8 @xl:grid-cols-2 @xl:gap-10 @4xl:gap-14">
<div className="flex flex-col gap-5">
<Header eyebrow={eyebrow} heading={heading} />
<Paragraphs paragraphs={paragraphs} />
{children}
</div>
{media ? (
<div
className={cn(
"@xl:self-start",
stickyMedia && "@xl:sticky @xl:top-6",
mediaPosition === "left" && "@xl:order-first",
)}
>
<Reveal>{media}</Reveal>
</div>
) : null}
</div>
</section>
);
}
"use client";
import { animate, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AnimatedNumberProps {
value: number;
duration?: number;
format?: (n: number) => string;
className?: string;
startOnView?: boolean;
}
export function AnimatedNumber({
value,
duration = 1.2,
format = (n) => Math.round(n).toLocaleString("en-US"),
className,
startOnView = true,
}: AnimatedNumberProps) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
if (startOnView && !inView) return;
if (reduce) {
fromRef.current = value;
setDisplay(value);
return;
}
const controls = animate(fromRef.current, value, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setDisplay(v),
});
fromRef.current = value;
return () => controls.stop();
}, [value, duration, inView, startOnView, reduce]);
return (
<span ref={ref} className={cn("tabular-nums", className)}>
{format(display)}
</span>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)
// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form
// control: it picks one *value* with radiogroup/radio semantics, can post that
// value with a form via `name`, supports disabled options, icon-only options
// and a full-width layout, and its thumb is a raised card surface rather than
// a filled accent pill. Reach for Tabs when each option owns content below it.
export type SegmentedControlSize = "sm" | "md" | "lg";
export interface SegmentedControlOption {
value: string;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
/** Required when the option has only an icon. */
"aria-label"?: string;
}
const trackSize: Record<SegmentedControlSize, string> = {
sm: "h-8 p-0.5 text-xs",
md: "h-10 p-1 text-sm",
lg: "h-12 p-1 text-sm",
};
const itemSize: Record<SegmentedControlSize, string> = {
sm: "gap-1.5 px-2.5 [&_svg]:size-3.5",
md: "gap-2 px-3.5 [&_svg]:size-4",
lg: "gap-2 px-5 [&_svg]:size-[18px]",
};
function useControllable<T>(
value: T | undefined,
defaultValue: T,
onChange: ((next: T) => void) | undefined,
) {
const [internal, setInternal] = useState<T>(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: T) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
/** Index of the next enabled option from `from` in `dir`, wrapping around. */
function nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {
const n = options.length;
for (let step = 1; step <= n; step++) {
const i = (((from + dir * step) % n) + n) % n;
if (!options[i].disabled) return i;
}
return from;
}
export interface SegmentedControlProps {
options: SegmentedControlOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
size?: SegmentedControlSize;
/** Stretch to the container width with equal-width segments. */
fullWidth?: boolean;
disabled?: boolean;
/** When set, a hidden input posts the value with the surrounding form. */
name?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
thumbClassName?: string;
}
export function SegmentedControl({
options,
value,
defaultValue,
onValueChange,
size = "md",
fullWidth = false,
disabled = false,
name,
className,
thumbClassName,
...aria
}: SegmentedControlProps) {
const [current, setValue] = useControllable(
value,
defaultValue ?? options.find((o) => !o.disabled)?.value ?? "",
onValueChange,
);
const reduce = useReducedMotion();
const layoutId = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));
const selectedIndex = items.findIndex((o) => o.value === current);
// Roving tab stop: the checked radio, or the first enabled one when none is.
const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled
? selectedIndex
: items.findIndex((o) => !o.disabled);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = nextEnabled(items, index, 1);
break;
case "ArrowLeft":
case "ArrowUp":
next = nextEnabled(items, index, -1);
break;
case "Home":
next = nextEnabled(items, -1, 1);
break;
case "End":
next = nextEnabled(items, items.length, -1);
break;
default:
return;
}
event.preventDefault();
if (next === null || items[next].disabled) return;
// Selection follows focus, as the WAI-ARIA radio group pattern prescribes.
refs.current[next]?.focus();
if (items[next].value !== current) setValue(items[next].value);
};
return (
// layoutRoot keeps the thumb's shared-layout projection local, so scrolled
// or fixed ancestors never leak offsets into its glide.
<motion.div
layoutRoot
role="radiogroup"
aria-disabled={disabled || undefined}
{...aria}
className={cn(
"relative isolate items-stretch rounded-xl border border-border bg-muted",
fullWidth ? "flex w-full" : "inline-flex",
trackSize[size],
disabled && "opacity-60",
className,
)}
>
{name ? <input type="hidden" name={name} value={current} /> : null}
{items.map((option, index) => {
const checked = option.value === current;
const iconOnly = !option.label;
return (
<motion.button
key={option.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={option["aria-label"]}
disabled={option.disabled}
tabIndex={index === tabStop ? 0 : -1}
data-state={checked ? "checked" : "unchecked"}
onClick={() => !checked && setValue(option.value)}
onKeyDown={(e) => onKeyDown(e, index)}
whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted",
"disabled:cursor-not-allowed disabled:opacity-50",
fullWidth && "flex-1",
itemSize[size],
iconOnly && "aspect-square px-0",
checked
? "text-foreground"
: "text-muted-foreground enabled:hover:text-foreground",
)}
>
{checked ? (
<motion.span
layoutId={layoutId}
aria-hidden
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm",
thumbClassName,
)}
/>
) : null}
{option.icon ? (
<span aria-hidden className="inline-flex shrink-0">
{option.icon}
</span>
) : null}
{option.label ? <span className="truncate">{option.label}</span> : null}
</motion.button>
);
})}
</motion.div>
);
}
/* ------------------------------------------------------------------------ */
/* ToggleGroup */
/* ------------------------------------------------------------------------ */
export interface ToggleGroupItem {
value: string;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
/** Required when the item has only an icon. */
"aria-label"?: string;
}
type ToggleGroupBase = {
items: ToggleGroupItem[];
size?: SegmentedControlSize;
disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
};
export type ToggleGroupSingleProps = ToggleGroupBase & {
type: "single";
/** The pressed item, or `""` when none is pressed. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Disallow un-pressing the active item, so one is always pressed. */
required?: boolean;
};
export type ToggleGroupMultipleProps = ToggleGroupBase & {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
const toggleSize: Record<SegmentedControlSize, string> = {
sm: "h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
md: "h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4",
lg: "h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]",
};
export function ToggleGroup(props: ToggleGroupProps) {
const { items, size = "md", disabled = false, className } = props;
const reduce = useReducedMotion();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const [focusIndex, setFocusIndex] = useState<number | null>(null);
// Normalise both modes to a string[] internally.
const single = props.type === "single";
const [current, setCurrent] = useControllable<string[]>(
props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),
single
? props.defaultValue
? [props.defaultValue as string]
: []
: ((props.defaultValue as string[] | undefined) ?? []),
(next) => {
if (props.type === "single") props.onValueChange?.(next[0] ?? "");
else props.onValueChange?.(next);
},
);
const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));
const firstEnabled = list.findIndex((i) => !i.disabled);
const tabStop =
focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;
const toggle = (itemValue: string) => {
const pressed = current.includes(itemValue);
if (props.type === "single") {
if (pressed && props.required) return;
setCurrent(pressed ? [] : [itemValue]);
} else {
setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);
}
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = nextEnabled(list, index, 1);
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = nextEnabled(list, index, -1);
else if (event.key === "Home") next = nextEnabled(list, -1, 1);
else if (event.key === "End") next = nextEnabled(list, list.length, -1);
if (next === null) return;
event.preventDefault();
// Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).
setFocusIndex(next);
refs.current[next]?.focus();
};
return (
<motion.div
role="group"
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
className={cn(
"inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1",
disabled && "opacity-60",
className,
)}
>
{list.map((item, index) => {
const pressed = current.includes(item.value);
return (
<motion.button
key={item.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
aria-pressed={pressed}
aria-label={item["aria-label"]}
disabled={item.disabled}
tabIndex={index === tabStop ? 0 : -1}
data-state={pressed ? "on" : "off"}
onClick={() => {
setFocusIndex(index);
toggle(item.value);
}}
onFocus={() => setFocusIndex(index)}
onKeyDown={(e) => onKeyDown(e, index)}
whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}
transition={SPRING_PRESS}
className={cn(
"relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
"disabled:cursor-not-allowed disabled:opacity-50",
toggleSize[size],
pressed
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
)}
>
<AnimatePresence initial={false}>
{pressed ? (
<motion.span
key="on"
aria-hidden
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}
transition={SPRING_SWAP}
className="absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong"
/>
) : null}
</AnimatePresence>
{item.icon ? (
<span aria-hidden className="inline-flex shrink-0">
{item.icon}
</span>
) : null}
{item.label ? <span>{item.label}</span> : null}
</motion.button>
);
})}
</motion.div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
variant?"split" | "stats" | "quote""split": copy beside media (media sticks while the copy scrolls on wide containers). "stats": copy plus rolling stat callouts. "quote": a pull quote with large quote marks and its author.
spliteyebrow?ReactNode—heading?ReactNode—paragraphs?ReactNode[]Body copy; each entry is revealed as it scrolls into view.
—children?ReactNodeExtra content after the paragraphs (CTA, list).
—media?ReactNode`variant="split"`: media node (image, mock UI, illustration).
—mediaPosition?"left" | "right"rightstickyMedia?boolean`variant="split"`: keep the media pinned while the copy scrolls. Default true.
truestats?ContentStat[]`variant="stats"`: up to 3 reads best.
[]quote?ReactNode`variant="quote"`: the quote itself, without quote marks.
—author?ContentQuoteAuthor—locale?stringNumber formatting locale. Default "en-US" so server and client agree.
en-USclassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated