Footer Section
Site footer with a brand column of logo, tagline and social icons that lift on hover, link columns that collapse into disclosures in narrow containers, a newsletter signup that validates email and runs idle, loading, success and error states from an onSubscribe promise, and a bottom bar with legal links and a theme toggle slot.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion next-themes 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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/blocks/footer-section
import { ChevronDown, Send } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { type FormEvent, type ReactNode, useEffect, useId, useRef, useState } from "react";
import { type ButtonState, StatefulButton } from "@/components/motion/button";
import { EASE_OUT_CSS, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface FooterLink {
label: ReactNode;
href: string;
}
export interface FooterLinkColumn {
title: string;
links: FooterLink[];
}
export interface FooterSocialLink {
/** Accessible name, e.g. "GitHub". */
label: string;
href: string;
icon: ReactNode;
}
export interface FooterNewsletter {
title?: ReactNode;
description?: ReactNode;
placeholder?: string;
buttonLabel?: ReactNode;
successMessage?: ReactNode;
}
export interface FooterSectionProps {
logo?: ReactNode;
tagline?: ReactNode;
socials?: FooterSocialLink[];
columns?: FooterLinkColumn[];
/** Newsletter copy. Shown when `onSubscribe` is set. */
newsletter?: FooterNewsletter;
/** Resolve to confirm the signup; reject (or throw) to show the error state. */
onSubscribe?: (email: string) => Promise<void> | void;
/** Owner after the © sign, e.g. "Acme Inc." */
copyright?: ReactNode;
/** Pass the year in; it is never computed during render. */
year?: number;
legalLinks?: FooterLink[];
/** Slot at the end of the bottom bar, e.g. a theme toggle. */
themeToggle?: ReactNode;
className?: string;
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
export function isValidEmail(value: string) {
return EMAIL_PATTERN.test(value.trim());
}
function SocialLink({ social }: { social: FooterSocialLink }) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const lift = !reduce && canHover;
return (
<motion.a
href={social.href}
aria-label={social.label}
target="_blank"
rel="noreferrer noopener"
whileHover={lift ? { y: -3 } : undefined}
whileTap={reduce ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
className="grid size-9 place-items-center rounded-full border border-border bg-card text-muted-foreground outline-none transition-colors hover:border-border-strong hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg]:size-4"
>
{social.icon}
</motion.a>
);
}
function LinkColumn({ column }: { column: FooterLinkColumn }) {
const [open, setOpen] = useState(false);
const id = useId();
const listId = `${id}-links`;
return (
<div className="border-b border-border @md:border-b-0">
{/* Narrow containers: the title is a disclosure button. */}
<button
type="button"
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-full items-center justify-between text-left text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50 @md:hidden"
>
{column.title}
<ChevronDown
aria-hidden
className={cn(
"size-4 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none",
open && "rotate-180",
)}
/>
</button>
{/* Wide containers: a plain heading, list always shown. */}
<h3 className="hidden text-sm font-medium text-foreground @md:block">
{column.title}
</h3>
{/* Grid-row 0fr→1fr is a layout animation on purpose: the collapsed
column has to push the following ones down. `invisible` keeps
closed links out of tab order and the a11y tree on narrow widths. */}
<div
id={listId}
style={{ transitionTimingFunction: EASE_OUT_CSS }}
className={cn(
"grid transition-[grid-template-rows,visibility] duration-300 motion-reduce:transition-none @md:visible @md:grid-rows-[1fr]",
open ? "visible grid-rows-[1fr]" : "invisible grid-rows-[0fr]",
)}
>
<ul className="flex flex-col gap-2.5 overflow-hidden pb-4 @md:mt-4 @md:pb-0">
{column.links.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="rounded-sm text-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
</div>
);
}
function NewsletterForm({
newsletter,
onSubscribe,
}: {
newsletter: FooterNewsletter;
onSubscribe: (email: string) => Promise<void> | void;
}) {
const id = useId();
const [email, setEmail] = useState("");
const [state, setState] = useState<ButtonState>("idle");
const [error, setError] = useState<string | null>(null);
const timer = useRef<number | undefined>(undefined);
useEffect(
() => () => {
if (timer.current) window.clearTimeout(timer.current);
},
[],
);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (state === "loading" || state === "success") return;
if (!isValidEmail(email)) {
setError("Enter a valid email address.");
return;
}
setError(null);
setState("loading");
try {
await onSubscribe(email.trim());
setState("success");
} catch {
setState("error");
setError("Something went wrong. Try again.");
timer.current = window.setTimeout(() => setState("idle"), 2400);
}
};
const done = state === "success";
return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium text-foreground">
{newsletter.title ?? "Stay in the loop"}
</h3>
{newsletter.description ? (
<p className="text-sm leading-6 text-muted-foreground">
{newsletter.description}
</p>
) : null}
<form noValidate onSubmit={submit} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label htmlFor={`${id}-email`} className="sr-only">
Email address
</label>
<input
id={`${id}-email`}
type="email"
inputMode="email"
autoComplete="email"
value={email}
disabled={done}
aria-invalid={error ? true : undefined}
aria-describedby={`${id}-status`}
onChange={(event) => {
setEmail(event.target.value);
if (error && isValidEmail(event.target.value)) setError(null);
}}
placeholder={newsletter.placeholder ?? "you@example.com"}
className={cn(
"h-10 min-w-0 flex-1 rounded-full border bg-card px-4 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 disabled:opacity-60",
error
? "border-destructive focus-visible:ring-destructive/30"
: "border-border focus-visible:border-border-strong focus-visible:ring-ring/40",
)}
/>
<StatefulButton
type="submit"
state={state}
loadingText="Joining"
successText="Subscribed"
errorText="Retry"
icon={<Send aria-hidden className="size-3.5" />}
className="shrink-0"
>
{newsletter.buttonLabel ?? "Subscribe"}
</StatefulButton>
</div>
<p
id={`${id}-status`}
aria-live="polite"
className={cn(
"min-h-5 text-xs",
error ? "text-destructive" : "text-muted-foreground",
)}
>
{error ??
(done
? (newsletter.successMessage ?? "Thanks! Check your inbox to confirm.")
: "")}
</p>
</form>
</div>
);
}
/**
* Site footer: brand column (logo, tagline, social icons that lift on hover),
* link columns that collapse into disclosures in narrow containers, a
* newsletter signup with idle/loading/success/error states driven by the
* `onSubscribe` promise, and a bottom bar with copyright, legal links and an
* optional theme toggle slot. Layout follows the footer's own container.
*/
export function FooterSection({
logo,
tagline,
socials = [],
columns = [],
newsletter = {},
onSubscribe,
copyright,
year,
legalLinks = [],
themeToggle,
className,
}: FooterSectionProps) {
const hasNewsletter = Boolean(onSubscribe);
return (
<footer
className={cn(
"@container w-full border-t border-border text-foreground",
className,
)}
>
<div
className={cn(
"grid gap-8 py-10 @xl:grid-cols-2 @xl:gap-x-10",
hasNewsletter
? "@4xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_minmax(0,1.2fr)]"
: "@4xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]",
)}
>
<div className="order-1 flex flex-col gap-4">
{logo ? <div className="flex items-center">{logo}</div> : null}
{tagline ? (
<p className="max-w-xs text-pretty text-sm leading-6 text-muted-foreground">
{tagline}
</p>
) : null}
{socials.length ? (
<ul className="flex flex-wrap items-center gap-2">
{socials.map((social) => (
<li key={social.href}>
<SocialLink social={social} />
</li>
))}
</ul>
) : null}
</div>
{columns.length ? (
<nav
aria-label="Footer"
className={cn(
"order-2 grid @md:grid-cols-3 @md:gap-6",
hasNewsletter && "@xl:order-3 @xl:col-span-2 @4xl:order-2 @4xl:col-span-1",
)}
>
{columns.map((column) => (
<LinkColumn key={column.title} column={column} />
))}
</nav>
) : null}
{onSubscribe ? (
<div className="order-3 @xl:order-2 @4xl:order-3">
<NewsletterForm newsletter={newsletter} onSubscribe={onSubscribe} />
</div>
) : null}
</div>
<div className="flex flex-col gap-3 border-t border-border py-5 text-xs text-muted-foreground @md:flex-row @md:items-center @md:justify-between">
<p>
© {year !== undefined ? `${year} ` : ""}
{copyright}
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{legalLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="rounded-sm outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
{link.label}
</a>
))}
{themeToggle ? <div className="flex items-center">{themeToggle}</div> : null}
</div>
</div>
</footer>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
// The ripple layer clips itself, so on a touch screen the button can
// stop clipping and let its enlarged hit area overhang.
ripple && "overflow-hidden pointer-coarse:overflow-visible",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useReducedMotion } from "motion/react";
import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
import { ActionSwapIcon } from "@/components/motion/action-swap";
import { EASE_OUT_CSS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ThemeVariant = "rectangle" | "circle" | "circle-blur" | "blinds";
export type RectStart =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "center"
| "bottom-up";
export interface ThemeToggleProps
extends Omit<ComponentPropsWithoutRef<"button">, "children" | "onClick"> {
/** Animation variant. Default: "rectangle". */
variant?: ThemeVariant;
/** Origin direction for the reveal. Default: "bottom-up". */
start?: RectStart;
iconClassName?: string;
}
const VT_STYLE_ID = "karaui-theme-toggle-vt";
// View transitions animate in CSS, not motion springs, so easing here is
// either EASE_OUT_CSS or a keyword. The circle variants keep the Material
// standard curve because their reveal expands symmetrically rather than
// decelerating. Durations differ per variant to match native OS mode switches.
const VT_CSS = `
html[data-karaui-vt="rect"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-karaui-vt="rect"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: karaui-rect-reveal 400ms ease-out;
}
html[data-karaui-vt="circle"]::view-transition-old(root),
html[data-karaui-vt="circle-blur"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-karaui-vt="circle"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: karaui-circle-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-karaui-vt="circle-blur"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: karaui-circle-blur-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-karaui-vt="blinds"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
/* Slats: a masked band widens inside every 72px tile, so the new theme opens
across the page like a shutter. The band edge has to be a registered custom
property — mask-image itself is not animatable, but it re-resolves every
frame the property ticks. mask-size fixes the tile at 72px rather than
letting a repeating gradient's last stop define it, which is what keeps the
20px soft edge from dragging the tile wider than the slat and leaving a
feathered gap that never closes; it also means both ends land clean, fully
transparent at -20px and fully opaque at 72px. Falling back to no mask
(unregistered property, so the var is invalid) reveals the page in one
step. */
@property --karaui-vt-slat {
syntax: "<length>";
inherits: false;
initial-value: 72px;
}
html[data-karaui-vt="blinds"]::view-transition-new(root) {
mix-blend-mode: normal;
mask-image: linear-gradient(
90deg,
#000 0 var(--karaui-vt-slat),
transparent calc(var(--karaui-vt-slat) + 20px)
);
mask-size: 72px 100%;
mask-repeat: repeat;
animation: karaui-blinds-reveal 700ms ${EASE_OUT_CSS};
}
@keyframes karaui-rect-reveal {
from { clip-path: var(--karaui-vt-from, inset(100% 0 0 0)); }
to { clip-path: inset(0 0 0 0); }
}
@keyframes karaui-circle-reveal {
from { clip-path: circle(0% at var(--karaui-vt-origin, 50% 100%)); }
to { clip-path: circle(150% at var(--karaui-vt-origin, 50% 100%)); }
}
@keyframes karaui-circle-blur-reveal {
from { clip-path: circle(0% at var(--karaui-vt-origin, 50% 100%)); filter: blur(8px); }
to { clip-path: circle(150% at var(--karaui-vt-origin, 50% 100%)); filter: blur(0px); }
}
@keyframes karaui-blinds-reveal {
from { --karaui-vt-slat: -20px; }
to { --karaui-vt-slat: 72px; }
}
`;
const RECT_FROM: Record<RectStart, string> = {
"top-left": "inset(0 100% 100% 0)",
"top-right": "inset(0 0 100% 100%)",
"bottom-left": "inset(100% 100% 0 0)",
"bottom-right":"inset(100% 0 0 100%)",
center: "inset(50% 50% 50% 50%)",
"bottom-up": "inset(100% 0 0 0)",
};
const CIRCLE_ORIGIN: Record<RectStart, string> = {
"top-left": "0% 0%",
"top-right": "100% 0%",
"bottom-left": "0% 100%",
"bottom-right":"100% 100%",
center: "50% 50%",
"bottom-up": "50% 100%",
};
export function useThemeToggle({
variant = "rectangle",
start = "bottom-up",
}: { variant?: ThemeVariant; start?: RectStart } = {}) {
const { setTheme, resolvedTheme } = useTheme();
const reduce = useReducedMotion() ?? false;
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (document.getElementById(VT_STYLE_ID)) return;
const el = document.createElement("style");
el.id = VT_STYLE_ID;
el.textContent = VT_CSS;
document.head.appendChild(el);
}, []);
const isDark = mounted && resolvedTheme === "dark";
const toggle = () => {
const next = isDark ? "light" : "dark";
if (reduce || !("startViewTransition" in document)) {
setTheme(next);
return;
}
const root = document.documentElement;
if (variant === "rectangle") {
root.style.setProperty("--karaui-vt-from", RECT_FROM[start]);
root.dataset.karauiVt = "rect";
} else if (variant === "blinds") {
// Slats sweep the whole viewport; there is no origin point to set.
root.dataset.karauiVt = "blinds";
} else {
root.style.setProperty("--karaui-vt-origin", CIRCLE_ORIGIN[start]);
root.dataset.karauiVt = variant;
}
const vt = (
document as Document & {
startViewTransition(cb: () => void): { finished: Promise<void> };
}
).startViewTransition(() => setTheme(next));
vt.finished.finally(() => {
delete root.dataset.karauiVt;
});
};
return { isDark, mounted, toggle };
}
export function ThemeToggle({
variant = "rectangle",
start = "bottom-up",
className,
iconClassName,
...rest
}: ThemeToggleProps) {
const { isDark, mounted, toggle } = useThemeToggle({ variant, start });
return (
<button
type="button"
aria-label={mounted && isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={toggle}
className={cn("flex items-center justify-center", className)}
{...rest}
>
{mounted ? (
<ActionSwapIcon
value={isDark ? "dark" : "light"}
animation="blur"
className={iconClassName}
>
{isDark ? (
<Sun className={iconClassName} />
) : (
<Moon className={iconClassName} />
)}
</ActionSwapIcon>
) : (
<span className={iconClassName} aria-hidden="true" />
)}
</button>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useState } from "react";
import type { ReactNode } from "react";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn(
"relative -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom",
className,
)}
style={{
clipPath: "inset(0 -999px)",
WebkitClipPath: "inset(0 -999px)",
}}
>
<span
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-[0.08em] inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
// Truncation lives on the layer that holds the text — the layer
// moves as a whole, so clipping it never eats the roll.
className="absolute left-0 top-[0.08em] inline-block max-w-full truncate will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
@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
logo?ReactNode—tagline?ReactNode—socials?FooterSocialLink[][]columns?FooterLinkColumn[][]newsletter?FooterNewsletterNewsletter copy. Shown when `onSubscribe` is set.
{}onSubscribe?((email: string) => void | Promise<void>)Resolve to confirm the signup; reject (or throw) to show the error state.
—copyright?ReactNodeOwner after the © sign, e.g. "Acme Inc."
—year?numberPass the year in; it is never computed during render.
—legalLinks?FooterLink[][]themeToggle?ReactNodeSlot at the end of the bottom bar, e.g. a theme toggle.
—className?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