"use client"
import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Spinner } from "@/components/ui/spinner"
import {
useButtonFeedback,
type ButtonFeedbackOptions,
type ButtonStatus,
} from "@/hooks/use-button-feedback"
const buttonVariants = cva(
"group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
"ghost-destructive":
"text-destructive hover:bg-destructive/10 hover:text-destructive aria-expanded:bg-destructive/10 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:hover:bg-destructive/20 forced-colors:border-0",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
success:
"bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
error:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-2.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
icon: "size-9 shrink-0",
"icon-xs":
"size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
"icon-lg": "size-10 shrink-0",
"icon-xl": "size-12 shrink-0 [&_svg:not([class*='size-'])]:size-5",
},
shape: {
default: "",
pill: "rounded-full in-data-[slot=button-group]:rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
shape: "default",
},
}
)
type ButtonVariant = Exclude<
NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
"success" | "error"
>
type ButtonClickEvent = Parameters<
NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]
type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
Omit<VariantProps<typeof buttonVariants>, "variant"> &
ButtonFeedbackOptions & {
variant?: ButtonVariant | null
onClick?: (event: ButtonClickEvent) => unknown
feedback?: boolean
loading?: boolean
status?: ButtonStatus
loadingLabel?: React.ReactNode
successLabel?: React.ReactNode
errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
}
const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
success: "text-success hover:text-success",
error:
"text-destructive hover:text-destructive motion-safe:animate-button-shake",
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as PromiseLike<unknown>).then === "function"
)
}
function useStatusTransition(status: ButtonStatus) {
const [shown, setShown] = React.useState(status)
const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
const [transitionKey, setTransitionKey] = React.useState(0)
if (status !== shown) {
setLeaving(shown)
setShown(status)
setTransitionKey((key) => key + 1)
}
React.useEffect(() => {
if (leaving === null) {
return
}
const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
return () => clearTimeout(timer)
}, [leaving, transitionKey])
return { leaving, transitionKey }
}
function useLayerWidth(status: ButtonStatus, active: boolean) {
const contentRef = React.useRef<HTMLSpanElement>(null)
const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
const observerRef = React.useRef<ResizeObserver | null>(null)
const statusRef = React.useRef(status)
const applyWidth = React.useCallback(() => {
const content = contentRef.current
const layer = layersRef.current.get(statusRef.current)
if (!content || !layer) {
return
}
const natural = parseFloat(getComputedStyle(layer).width)
if (!Number.isFinite(natural)) {
return
}
content.style.width = `${natural}px`
if (!content.hasAttribute("data-measured")) {
requestAnimationFrame(() => content.setAttribute("data-measured", ""))
}
}, [])
React.useLayoutEffect(() => {
statusRef.current = status
if (active) {
applyWidth()
}
}, [active, applyWidth, status])
React.useLayoutEffect(() => {
if (!active || typeof ResizeObserver === "undefined") {
return
}
const observer = new ResizeObserver(applyWidth)
observerRef.current = observer
layersRef.current.forEach((layer) => observer.observe(layer))
document.fonts?.addEventListener?.("loadingdone", applyWidth)
return () => {
observer.disconnect()
observerRef.current = null
document.fonts?.removeEventListener?.("loadingdone", applyWidth)
}
}, [active, applyWidth])
const layerRef = React.useCallback((node: HTMLElement | null) => {
if (!node) {
return
}
const layer = node.dataset.layer as ButtonStatus
layersRef.current.set(layer, node)
observerRef.current?.observe(node)
return () => {
observerRef.current?.unobserve(node)
if (layersRef.current.get(layer) === node) {
layersRef.current.delete(layer)
}
}
}, [])
return { contentRef, layerRef }
}
function composeHandlers<Event>(
first: ((event: Event) => void) | undefined,
second: (event: Event) => void
) {
return (event: Event) => {
first?.(event)
second(event)
}
}
function Button({
className,
variant = "default",
size = "default",
shape = "default",
children,
onClick,
disabled,
focusableWhenDisabled,
feedback,
loading,
status: statusProp,
onStatusChange,
onError,
resetAfter,
loadingLabel,
successLabel = "Done",
errorLabel = "Failed",
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: ButtonProps) {
const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
const status = statusProp ?? (loading ? "loading" : internal.status)
const requested =
Boolean(feedback) || loading !== undefined || statusProp !== undefined
const [enabled, setEnabled] = React.useState(requested)
if (requested && !enabled) {
setEnabled(true)
}
const { leaving, transitionKey } = useStatusTransition(status)
const iconOnly = size?.startsWith("icon") ?? false
const widthStatus =
status === "loading" && loadingLabel === undefined ? "idle" : status
const { contentRef, layerRef } = useLayerWidth(
widthStatus,
enabled && !iconOnly
)
const isLoading = status === "loading"
const quiet = quietVariants.includes(variant ?? "default")
const resolvedVariant =
!quiet && status === "success"
? "success"
: !quiet && status === "error"
? "error"
: variant
const resolvedErrorLabel =
typeof errorLabel !== "function"
? errorLabel
: internal.error !== undefined
? errorLabel(internal.error)
: "Failed"
const handleClick = (event: ButtonClickEvent) => {
if (feedback && internal.isPending()) {
return
}
const result = onClick?.(event)
if (feedback && isThenable(result)) {
internal.track(result)
}
}
const layerContent = (layer: ButtonStatus, animated: boolean) => {
if (layer === "idle") {
return children
}
if (layer === "loading") {
return (
<>
<Spinner aria-hidden size={null} animated={animated} />
{iconOnly ? null : loadingLabel}
</>
)
}
if (layer === "success") {
return (
<>
<IconCircleCheck
className={cn(
animated &&
"motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
)}
/>
{iconOnly ? null : successLabel}
</>
)
}
return (
<>
<IconAlertCircle />
{iconOnly ? null : resolvedErrorLabel}
</>
)
}
const button = (
<ButtonPrimitive
data-slot="button"
data-shape={shape ?? "default"}
data-status={enabled ? status : undefined}
aria-busy={isLoading || undefined}
disabled={disabled || isLoading}
focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
className={cn(
buttonVariants({ variant: resolvedVariant, size, shape }),
quiet && quietStatusClasses[status],
className
)}
onClick={handleClick}
onPointerEnter={composeHandlers(
onPointerEnter,
internal.buttonProps.onPointerEnter
)}
onPointerLeave={composeHandlers(
onPointerLeave,
internal.buttonProps.onPointerLeave
)}
onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
{...props}
>
{enabled ? (
<span
ref={contentRef}
data-slot="button-content"
className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
>
{statuses.map((layer) => {
const active = layer === status
const exiting = layer === leaving
const namesButton =
layer === "idle" && isLoading && loadingLabel === undefined
return (
<span
key={active ? `${layer}-${transitionKey}` : layer}
ref={layerRef}
data-slot="button-layer"
data-layer={layer}
aria-hidden={active || namesButton ? undefined : true}
className={cn(
"inline-flex items-center justify-center gap-[inherit] backface-hidden",
!active &&
!exiting &&
"pointer-events-none absolute opacity-0",
active &&
transitionKey > 0 &&
"origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
exiting &&
"pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
)}
>
{layerContent(layer, active || exiting)}
</span>
)
})}
</span>
) : (
children
)}
</ButtonPrimitive>
)
if (!enabled) {
return button
}
const announcement =
status === "loading"
? typeof loadingLabel === "string"
? loadingLabel
: "Loading"
: status === "success"
? typeof successLabel === "string"
? successLabel
: "Done"
: status === "error"
? typeof resolvedErrorLabel === "string"
? resolvedErrorLabel
: "Failed"
: ""
return (
<>
{button}
<span data-slot="button-status" role="status" className="sr-only">
{announcement}
</span>
</>
)
}
export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
Button Demo
By Preet Suthar
Try the interaction, copy the React source files, or download the complete example.
About Button Demo
Button Demo is an animated React and Tailwind CSS button by Preet Suthar. Try the interaction, copy the React source, or download the example with creator credit.
- Effects and categories
- Hover effects, Animated
- Using this example
- Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.
Usage, Tailwind and licensing questions
Read the source files
components/examples/button/demo.tsx
"use client"
import { Button } from "@/components/ui/button"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function fail(ms: number) {
await wait(ms)
throw new Error("Request failed")
}
export function ButtonDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button feedback onClick={() => wait(900)}>
Save changes
</Button>
<Button feedback variant="outline" onClick={() => fail(900)}>
Request that fails
</Button>
<Button feedback variant="secondary" onClick={() => wait(80)}>
Fast request
</Button>
</div>
)
}
components/ui/button.tsx
"use client"
import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Spinner } from "@/components/ui/spinner"
import {
useButtonFeedback,
type ButtonFeedbackOptions,
type ButtonStatus,
} from "@/hooks/use-button-feedback"
const buttonVariants = cva(
"group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
"ghost-destructive":
"text-destructive hover:bg-destructive/10 hover:text-destructive aria-expanded:bg-destructive/10 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:hover:bg-destructive/20 forced-colors:border-0",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
success:
"bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
error:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-2.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
icon: "size-9 shrink-0",
"icon-xs":
"size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
"icon-lg": "size-10 shrink-0",
"icon-xl": "size-12 shrink-0 [&_svg:not([class*='size-'])]:size-5",
},
shape: {
default: "",
pill: "rounded-full in-data-[slot=button-group]:rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
shape: "default",
},
}
)
type ButtonVariant = Exclude<
NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
"success" | "error"
>
type ButtonClickEvent = Parameters<
NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]
type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
Omit<VariantProps<typeof buttonVariants>, "variant"> &
ButtonFeedbackOptions & {
variant?: ButtonVariant | null
onClick?: (event: ButtonClickEvent) => unknown
feedback?: boolean
loading?: boolean
status?: ButtonStatus
loadingLabel?: React.ReactNode
successLabel?: React.ReactNode
errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
}
const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
success: "text-success hover:text-success",
error:
"text-destructive hover:text-destructive motion-safe:animate-button-shake",
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as PromiseLike<unknown>).then === "function"
)
}
function useStatusTransition(status: ButtonStatus) {
const [shown, setShown] = React.useState(status)
const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
const [transitionKey, setTransitionKey] = React.useState(0)
if (status !== shown) {
setLeaving(shown)
setShown(status)
setTransitionKey((key) => key + 1)
}
React.useEffect(() => {
if (leaving === null) {
return
}
const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
return () => clearTimeout(timer)
}, [leaving, transitionKey])
return { leaving, transitionKey }
}
function useLayerWidth(status: ButtonStatus, active: boolean) {
const contentRef = React.useRef<HTMLSpanElement>(null)
const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
const observerRef = React.useRef<ResizeObserver | null>(null)
const statusRef = React.useRef(status)
const applyWidth = React.useCallback(() => {
const content = contentRef.current
const layer = layersRef.current.get(statusRef.current)
if (!content || !layer) {
return
}
const natural = parseFloat(getComputedStyle(layer).width)
if (!Number.isFinite(natural)) {
return
}
content.style.width = `${natural}px`
if (!content.hasAttribute("data-measured")) {
requestAnimationFrame(() => content.setAttribute("data-measured", ""))
}
}, [])
React.useLayoutEffect(() => {
statusRef.current = status
if (active) {
applyWidth()
}
}, [active, applyWidth, status])
React.useLayoutEffect(() => {
if (!active || typeof ResizeObserver === "undefined") {
return
}
const observer = new ResizeObserver(applyWidth)
observerRef.current = observer
layersRef.current.forEach((layer) => observer.observe(layer))
document.fonts?.addEventListener?.("loadingdone", applyWidth)
return () => {
observer.disconnect()
observerRef.current = null
document.fonts?.removeEventListener?.("loadingdone", applyWidth)
}
}, [active, applyWidth])
const layerRef = React.useCallback((node: HTMLElement | null) => {
if (!node) {
return
}
const layer = node.dataset.layer as ButtonStatus
layersRef.current.set(layer, node)
observerRef.current?.observe(node)
return () => {
observerRef.current?.unobserve(node)
if (layersRef.current.get(layer) === node) {
layersRef.current.delete(layer)
}
}
}, [])
return { contentRef, layerRef }
}
function composeHandlers<Event>(
first: ((event: Event) => void) | undefined,
second: (event: Event) => void
) {
return (event: Event) => {
first?.(event)
second(event)
}
}
function Button({
className,
variant = "default",
size = "default",
shape = "default",
children,
onClick,
disabled,
focusableWhenDisabled,
feedback,
loading,
status: statusProp,
onStatusChange,
onError,
resetAfter,
loadingLabel,
successLabel = "Done",
errorLabel = "Failed",
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: ButtonProps) {
const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
const status = statusProp ?? (loading ? "loading" : internal.status)
const requested =
Boolean(feedback) || loading !== undefined || statusProp !== undefined
const [enabled, setEnabled] = React.useState(requested)
if (requested && !enabled) {
setEnabled(true)
}
const { leaving, transitionKey } = useStatusTransition(status)
const iconOnly = size?.startsWith("icon") ?? false
const widthStatus =
status === "loading" && loadingLabel === undefined ? "idle" : status
const { contentRef, layerRef } = useLayerWidth(
widthStatus,
enabled && !iconOnly
)
const isLoading = status === "loading"
const quiet = quietVariants.includes(variant ?? "default")
const resolvedVariant =
!quiet && status === "success"
? "success"
: !quiet && status === "error"
? "error"
: variant
const resolvedErrorLabel =
typeof errorLabel !== "function"
? errorLabel
: internal.error !== undefined
? errorLabel(internal.error)
: "Failed"
const handleClick = (event: ButtonClickEvent) => {
if (feedback && internal.isPending()) {
return
}
const result = onClick?.(event)
if (feedback && isThenable(result)) {
internal.track(result)
}
}
const layerContent = (layer: ButtonStatus, animated: boolean) => {
if (layer === "idle") {
return children
}
if (layer === "loading") {
return (
<>
<Spinner aria-hidden size={null} animated={animated} />
{iconOnly ? null : loadingLabel}
</>
)
}
if (layer === "success") {
return (
<>
<IconCircleCheck
className={cn(
animated &&
"motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
)}
/>
{iconOnly ? null : successLabel}
</>
)
}
return (
<>
<IconAlertCircle />
{iconOnly ? null : resolvedErrorLabel}
</>
)
}
const button = (
<ButtonPrimitive
data-slot="button"
data-shape={shape ?? "default"}
data-status={enabled ? status : undefined}
aria-busy={isLoading || undefined}
disabled={disabled || isLoading}
focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
className={cn(
buttonVariants({ variant: resolvedVariant, size, shape }),
quiet && quietStatusClasses[status],
className
)}
onClick={handleClick}
onPointerEnter={composeHandlers(
onPointerEnter,
internal.buttonProps.onPointerEnter
)}
onPointerLeave={composeHandlers(
onPointerLeave,
internal.buttonProps.onPointerLeave
)}
onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
{...props}
>
{enabled ? (
<span
ref={contentRef}
data-slot="button-content"
className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
>
{statuses.map((layer) => {
const active = layer === status
const exiting = layer === leaving
const namesButton =
layer === "idle" && isLoading && loadingLabel === undefined
return (
<span
key={active ? `${layer}-${transitionKey}` : layer}
ref={layerRef}
data-slot="button-layer"
data-layer={layer}
aria-hidden={active || namesButton ? undefined : true}
className={cn(
"inline-flex items-center justify-center gap-[inherit] backface-hidden",
!active &&
!exiting &&
"pointer-events-none absolute opacity-0",
active &&
transitionKey > 0 &&
"origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
exiting &&
"pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
)}
>
{layerContent(layer, active || exiting)}
</span>
)
})}
</span>
) : (
children
)}
</ButtonPrimitive>
)
if (!enabled) {
return button
}
const announcement =
status === "loading"
? typeof loadingLabel === "string"
? loadingLabel
: "Loading"
: status === "success"
? typeof successLabel === "string"
? successLabel
: "Done"
: status === "error"
? typeof resolvedErrorLabel === "string"
? resolvedErrorLabel
: "Failed"
: ""
return (
<>
{button}
<span data-slot="button-status" role="status" className="sr-only">
{announcement}
</span>
</>
)
}
export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
hooks/use-button-feedback.ts
import * as React from "react"
type ButtonStatus = "idle" | "loading" | "success" | "error"
type ButtonResetAfter = number | { success?: number; error?: number }
type ButtonFeedbackOptions = {
resetAfter?: ButtonResetAfter
onStatusChange?: (status: ButtonStatus) => void
onError?: (error: unknown) => void
}
const spinnerDelay = 150
const minimumSpinnerTime = 400
const resumeResetDelay = 600
const defaultResetAfter = { success: 2000, error: 4000 }
function resolveResetAfter(
resetAfter: ButtonResetAfter | undefined,
outcome: "success" | "error"
) {
if (typeof resetAfter === "number") {
return resetAfter
}
return resetAfter?.[outcome] ?? defaultResetAfter[outcome]
}
function useButtonFeedback(options: ButtonFeedbackOptions = {}) {
const [status, setStatus] = React.useState<ButtonStatus>("idle")
const [error, setError] = React.useState<unknown>(undefined)
const optionsRef = React.useRef(options)
const runRef = React.useRef(0)
const inFlightRef = React.useRef(false)
const timersRef = React.useRef<ReturnType<typeof setTimeout>[]>([])
const holdRef = React.useRef({ hovered: false, focused: false })
const resetPendingRef = React.useRef(false)
const statusRef = React.useRef<ButtonStatus>("idle")
React.useLayoutEffect(() => {
optionsRef.current = options
})
const clearTimers = React.useCallback(() => {
timersRef.current.forEach(clearTimeout)
timersRef.current = []
}, [])
React.useEffect(
() => () => {
runRef.current += 1
clearTimers()
},
[clearTimers]
)
const update = React.useCallback((next: ButtonStatus) => {
statusRef.current = next
setStatus(next)
optionsRef.current.onStatusChange?.(next)
}, [])
const later = React.useCallback((callback: () => void, delay: number) => {
const run = runRef.current
timersRef.current.push(
setTimeout(() => {
if (run === runRef.current) {
callback()
}
}, delay)
)
}, [])
const isHeld = React.useCallback(
() =>
statusRef.current === "error" &&
(holdRef.current.hovered || holdRef.current.focused),
[]
)
const resume = React.useCallback(() => {
if (resetPendingRef.current && !isHeld()) {
resetPendingRef.current = false
later(() => update("idle"), resumeResetDelay)
}
}, [isHeld, later, update])
const track = React.useCallback(
(action: PromiseLike<unknown> | (() => PromiseLike<unknown>)) => {
if (inFlightRef.current) {
return
}
const run = ++runRef.current
let spinnerShownAt: number | null = null
inFlightRef.current = true
resetPendingRef.current = false
clearTimers()
later(() => {
spinnerShownAt = Date.now()
update("loading")
}, spinnerDelay)
const settle = (outcome: "success" | "error", reason?: unknown) => {
if (run !== runRef.current) {
return
}
clearTimers()
const remaining =
spinnerShownAt === null
? 0
: Math.max(0, minimumSpinnerTime - (Date.now() - spinnerShownAt))
later(() => {
inFlightRef.current = false
if (outcome === "error") {
setError(reason)
optionsRef.current.onError?.(reason)
}
update(outcome)
later(
() => {
if (isHeld()) {
resetPendingRef.current = true
} else {
update("idle")
}
},
resolveResetAfter(optionsRef.current.resetAfter, outcome)
)
}, remaining)
}
try {
const promise = typeof action === "function" ? action() : action
Promise.resolve(promise).then(
() => settle("success"),
(reason: unknown) => settle("error", reason)
)
} catch (reason) {
settle("error", reason)
}
},
[clearTimers, isHeld, later, update]
)
const reset = React.useCallback(() => {
runRef.current += 1
clearTimers()
inFlightRef.current = false
resetPendingRef.current = false
update("idle")
}, [clearTimers, update])
const handlers = React.useMemo(
() => ({
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType === "mouse") {
holdRef.current.hovered = true
}
},
onPointerLeave: () => {
holdRef.current.hovered = false
resume()
},
onFocus: (event: React.FocusEvent<HTMLElement>) => {
holdRef.current.focused = event.currentTarget.matches(":focus-visible")
},
onBlur: () => {
holdRef.current.focused = false
resume()
},
}),
[resume]
)
return {
status,
error,
track,
reset,
isPending: () => inFlightRef.current,
buttonProps: { status, ...handlers },
}
}
export { useButtonFeedback }
export type { ButtonFeedbackOptions, ButtonResetAfter, ButtonStatus }
components/ui/spinner.tsx
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import {
useDelayedLoading,
type DelayedLoadingOptions,
} from "@/hooks/use-delayed-loading"
const spinnerVariants = cva("shrink-0", {
variants: {
size: {
sm: "size-3.5",
default: "size-4",
lg: "size-5",
xl: "size-8",
},
},
defaultVariants: {
size: "default",
},
})
type SpinnerVariant = "ticks" | "ring"
type SpinnerProps = Omit<React.ComponentProps<"svg">, "children"> &
VariantProps<typeof spinnerVariants> & {
variant?: SpinnerVariant
label?: string
animated?: boolean
loading?: boolean
delay?: number
minDuration?: number
}
const ticks = Array.from({ length: 8 }, (_, index) => index)
function Spinner({
className,
variant = "ticks",
size = "default",
label = "Loading",
animated = true,
loading,
delay,
minDuration,
...props
}: SpinnerProps) {
const delayed = useDelayedLoading(loading ?? false, { delay, minDuration })
const shown = loading === undefined || delayed
if (!shown) {
return null
}
const hidden =
props["aria-hidden"] === true || props["aria-hidden"] === "true"
return (
<svg
viewBox="0 0 24 24"
fill="none"
role={hidden ? undefined : "status"}
aria-label={hidden ? undefined : label}
data-slot="spinner"
data-variant={variant}
className={cn(
spinnerVariants({ size }),
animated &&
(variant === "ticks"
? "motion-safe:animate-spinner-ticks"
: "motion-safe:animate-spinner-rotate"),
animated && "motion-reduce:animate-spinner-pulse",
loading !== undefined &&
"transition-opacity duration-200 ease-out motion-reduce:transition-none starting:opacity-0",
className
)}
{...props}
>
{variant === "ticks" ? (
ticks.map((index) => (
<line
key={index}
x1="12"
y1="2.75"
x2="12"
y2="6.75"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
opacity={1 - index * 0.11}
transform={`rotate(${-index * 45} 12 12)`}
/>
))
) : (
<>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth="2.5"
opacity="0.2"
/>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray="16 60"
transform="rotate(-90 12 12)"
className={cn(animated && "motion-safe:animate-spinner-dash")}
/>
</>
)}
</svg>
)
}
export { Spinner, spinnerVariants, useDelayedLoading }
export type { DelayedLoadingOptions, SpinnerProps, SpinnerVariant }
hooks/use-delayed-loading.ts
import * as React from "react"
type DelayedLoadingOptions = {
delay?: number
minDuration?: number
}
function useDelayedLoading(
loading: boolean,
{ delay = 150, minDuration = 400 }: DelayedLoadingOptions = {}
) {
const [visible, setVisible] = React.useState(false)
const shownAt = React.useRef(0)
React.useEffect(() => {
if (loading === visible) {
return
}
if (loading) {
const timer = setTimeout(
() => {
shownAt.current = Date.now()
setVisible(true)
},
Math.max(0, delay)
)
return () => clearTimeout(timer)
}
const remaining = Math.max(0, minDuration - (Date.now() - shownAt.current))
const timer = setTimeout(() => setVisible(false), remaining)
return () => clearTimeout(timer)
}, [loading, visible, delay, minDuration])
return visible
}
export { useDelayedLoading }
export type { DelayedLoadingOptions }
demo.tsx
export { ButtonDemo as default } from "@/components/examples/button/demo";CSS
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-mono: var(--font-paper-mono), ui-monospace, Menlo, SFMono-Regular, Monaco,
Consolas, "DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace;
--spacing: 0.25rem;
--text-xs: 0.6875rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.8125rem;
--text-sm--line-height: calc(1.25 / 0.875);
--font-weight-medium: 500;
--radius-md: calc(var(--radius) * 0.8);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: -apple-system, BlinkMacSystemFont, var(--font-inter), ui-sans-serif,
sans-serif;
--default-mono-font-family: var(--font-mono);
--color-accent: var(--accent);
--animate-button-state-in: button-state-in 220ms cubic-bezier(0.2, 0, 0, 1)
both;
--animate-button-state-out: button-state-out 220ms cubic-bezier(0.2, 0, 0, 1)
both;
--animate-button-draw-circle: button-draw 240ms cubic-bezier(0.2, 0, 0, 1)
both;
--animate-button-draw-check: button-draw 180ms cubic-bezier(0.2, 0, 0, 1)
180ms both;
--animate-spinner-rotate: spinner-rotate 0.9s linear infinite;
--animate-spinner-dash: spinner-dash 1.5s ease-in-out infinite;
--animate-spinner-ticks: spinner-rotate 0.8s steps(8, end) infinite;
--animate-spinner-pulse: spinner-pulse 1.6s ease-in-out infinite;
--animate-button-shake: button-shake 320ms
cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.flex {
display: flex;
}
.hidden {
display: none;
}
.inline-flex {
display: inline-flex;
}
.inline-grid {
display: inline-grid;
}
.size-3\.5 {
width: calc(var(--spacing) * 3.5);
height: calc(var(--spacing) * 3.5);
}
.size-4 {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.size-5 {
width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5);
}
.size-6 {
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
.size-8 {
width: calc(var(--spacing) * 8);
height: calc(var(--spacing) * 8);
}
.size-9 {
width: calc(var(--spacing) * 9);
height: calc(var(--spacing) * 9);
}
.size-10 {
width: calc(var(--spacing) * 10);
height: calc(var(--spacing) * 10);
}
.size-12 {
width: calc(var(--spacing) * 12);
height: calc(var(--spacing) * 12);
}
.h-6 {
height: calc(var(--spacing) * 6);
}
.h-8 {
height: calc(var(--spacing) * 8);
}
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
.shrink-0 {
flex-shrink: 0;
}
.origin-bottom {
transform-origin: bottom;
}
.origin-top {
transform-origin: top;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.grid-cols-\[minmax\(0\,1fr\)\] {
grid-template-columns: minmax(0,1fr);
}
.flex-wrap {
flex-wrap: wrap;
}
.place-items-center {
place-items: center;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.gap-1 {
gap: var(--spacing);
}
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.gap-\[inherit\] {
gap: inherit;
}
.overflow-x-clip {
overflow-x: clip;
}
.rounded-\[min\(var\(--radius-md\)\,8px\)\] {
border-radius: min(var(--radius-md), 8px);
}
.rounded-\[min\(var\(--radius-md\)\,10px\)\] {
border-radius: min(var(--radius-md), 10px);
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-md {
border-radius: calc(var(--radius) * 0.8);
}
.bg-background {
background-color: var(--background);
}
.bg-destructive {
background-color: var(--destructive);
}
.bg-destructive\/10 {
background-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
}
}
.bg-primary {
background-color: var(--primary);
}
.bg-secondary {
background-color: var(--secondary);
}
.bg-success {
background-color: var(--success);
}
.bg-clip-padding {
background-clip: padding-box;
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
.px-2\.5 {
padding-inline: calc(var(--spacing) * 2.5);
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.whitespace-nowrap {
white-space: nowrap;
}
.text-destructive {
color: var(--destructive);
}
.text-destructive-foreground {
color: var(--destructive-foreground);
}
.text-primary {
color: var(--primary);
}
.text-primary-foreground {
color: var(--primary-foreground);
}
.text-secondary-foreground {
color: var(--secondary-foreground);
}
.text-success {
color: var(--success);
}
.text-success-foreground {
color: var(--success-foreground);
}
.underline-offset-4 {
text-underline-offset: 4px;
}
.opacity-0 {
opacity: 0%;
}
.ring {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.inset-ring-\(length\:--hairline\) {
--tw-inset-ring-shadow: inset 0 0 0 var(--hairline) var(--tw-inset-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.inset-ring-border {
--tw-inset-ring-color: var(--border);
}
.inset-ring-transparent {
--tw-inset-ring-color: transparent;
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.transition-\[color\,background-color\,box-shadow\,opacity\,translate\,scale\] {
transition-property: color,background-color,box-shadow,opacity,translate,scale;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-opacity {
transition-property: opacity;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-200 {
--tw-duration: 200ms;
transition-duration: 200ms;
}
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.ease-out-quint {
--tw-ease: cubic-bezier(0.23, 1, 0.32, 1);
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.outline-none {
--tw-outline-style: none;
outline-style: none;
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
.backface-hidden {
backface-visibility: hidden;
}
:is(.\*\:col-start-1 > *) {
grid-column-start: 1;
}
:is(.\*\:row-start-1 > *) {
grid-row-start: 1;
}
@media (hover: hover) {
.hover\:bg-\[color-mix\(in_oklch\,var\(--secondary\)\,var\(--foreground\)_5\%\)\]:hover {
background-color: var(--secondary);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-\[color-mix\(in_oklch\,var\(--secondary\)\,var\(--foreground\)_5\%\)\]:hover {
background-color: color-mix(in oklch,var(--secondary),var(--foreground) 5%);
}
}
.hover\:bg-destructive\/10:hover {
background-color: var(--destructive);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-destructive\/10:hover {
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
}
}
.hover\:bg-destructive\/20:hover {
background-color: var(--destructive);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-destructive\/20:hover {
background-color: color-mix(in oklab, var(--destructive) 20%, transparent);
}
}
.hover\:bg-destructive\/90:hover {
background-color: var(--destructive);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-destructive\/90:hover {
background-color: color-mix(in oklab, var(--destructive) 90%, transparent);
}
}
.hover\:bg-muted:hover {
background-color: var(--muted);
}
.hover\:bg-primary\/80:hover {
background-color: var(--primary);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-primary\/80:hover {
background-color: color-mix(in oklab, var(--primary) 80%, transparent);
}
}
.hover\:bg-success\/90:hover {
background-color: var(--success);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-success\/90:hover {
background-color: color-mix(in oklab, var(--success) 90%, transparent);
}
}
.hover\:text-destructive:hover {
color: var(--destructive);
}
.hover\:text-foreground:hover {
color: var(--foreground);
}
.hover\:text-success:hover {
color: var(--success);
}
.hover\:underline:hover {
text-decoration-line: underline;
}
}
.focus-visible\:ring-3:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.focus-visible\:ring-destructive\/70:focus-visible {
--tw-ring-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--destructive) 70%, transparent);
}
}
.focus-visible\:ring-focus-ring:focus-visible {
--tw-ring-color: var(--foreground);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--foreground) 40%, transparent);
}
}
.focus-visible\:ring-success\/70:focus-visible {
--tw-ring-color: var(--success);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--success) 70%, transparent);
}
}
.focus-visible\:inset-ring-destructive\/40:focus-visible {
--tw-inset-ring-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-inset-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent);
}
}
.focus-visible\:inset-ring-ring:focus-visible {
--tw-inset-ring-color: var(--ring);
}
.focus-visible\:inset-ring-success\/40:focus-visible {
--tw-inset-ring-color: var(--success);
@supports (color: color-mix(in lab, red, red)) {
--tw-inset-ring-color: color-mix(in oklab, var(--success) 40%, transparent);
}
}
.focus-visible\:outline-hidden:focus-visible {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
.active\:duration-100:active {
--tw-duration: 100ms;
transition-duration: 100ms;
}
.active\:not-aria-\[haspopup\]\:not-aria-busy\:translate-y-px:active:not([aria-haspopup]):not([aria-busy="true"]) {
--tw-translate-y: 1px;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.disabled\:pointer-events-none:disabled {
pointer-events: none;
}
.disabled\:opacity-50:disabled {
opacity: 50%;
}
:where([data-slot="button-group"]) .in-data-\[slot\=button-group\]\:rounded-full {
border-radius: calc(infinity * 1px);
}
:where([data-slot="button-group"]) .in-data-\[slot\=button-group\]\:rounded-md {
border-radius: calc(var(--radius) * 0.8);
}
.has-data-\[icon\=inline-end\]\:pe-1\.5:has([data-icon="inline-end"]) {
padding-inline-end: calc(var(--spacing) * 1.5);
}
.has-data-\[icon\=inline-end\]\:pe-2:has([data-icon="inline-end"]) {
padding-inline-end: calc(var(--spacing) * 2);
}
.has-data-\[icon\=inline-start\]\:ps-1\.5:has([data-icon="inline-start"]) {
padding-inline-start: calc(var(--spacing) * 1.5);
}
.has-data-\[icon\=inline-start\]\:ps-2:has([data-icon="inline-start"]) {
padding-inline-start: calc(var(--spacing) * 2);
}
.aria-busy\:cursor-progress[aria-busy="true"] {
cursor: progress;
}
.aria-expanded\:bg-destructive\/10[aria-expanded="true"] {
background-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
}
}
.aria-expanded\:bg-muted[aria-expanded="true"] {
background-color: var(--muted);
}
.aria-expanded\:bg-secondary[aria-expanded="true"] {
background-color: var(--secondary);
}
.aria-expanded\:text-foreground[aria-expanded="true"] {
color: var(--foreground);
}
.aria-expanded\:text-secondary-foreground[aria-expanded="true"] {
color: var(--secondary-foreground);
}
.aria-invalid\:ring-3[aria-invalid="true"] {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.aria-invalid\:ring-destructive\/20[aria-invalid="true"] {
--tw-ring-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent);
}
}
.aria-invalid\:inset-ring-destructive[aria-invalid="true"] {
--tw-inset-ring-color: var(--destructive);
}
@media (prefers-reduced-motion: no-preference) {
.motion-safe\:animate-button-shake {
animation: var(--animate-button-shake);
}
.motion-safe\:animate-button-state-in {
animation: var(--animate-button-state-in);
}
.motion-safe\:animate-button-state-out {
animation: var(--animate-button-state-out);
}
.motion-safe\:animate-spinner-dash {
animation: var(--animate-spinner-dash);
}
.motion-safe\:animate-spinner-rotate {
animation: var(--animate-spinner-rotate);
}
.motion-safe\:animate-spinner-ticks {
animation: var(--animate-spinner-ticks);
}
.motion-safe\:active\:not-aria-\[haspopup\]\:not-aria-busy\:scale-\[0\.97\]:active:not([aria-haspopup]):not([aria-busy="true"]) {
scale: 0.97;
}
.motion-safe\:data-measured\:transition-\[width\][data-measured] {
transition-property: width;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.motion-safe\:data-measured\:duration-220[data-measured] {
--tw-duration: 220ms;
transition-duration: 220ms;
}
.motion-safe\:data-measured\:ease-\[cubic-bezier\(0\.2\,0\,0\,1\)\][data-measured] {
--tw-ease: cubic-bezier(0.2,0,0,1);
transition-timing-function: cubic-bezier(0.2,0,0,1);
}
}
@media (prefers-reduced-motion: reduce) {
.motion-reduce\:animate-spinner-pulse {
animation: var(--animate-spinner-pulse);
}
.motion-reduce\:transition-none {
transition-property: none;
}
}
.dark\:bg-destructive\/20:where(.dark, .dark *) {
background-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--destructive) 20%, transparent);
}
}
.dark\:bg-input\/30:where(.dark, .dark *) {
background-color: var(--input);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--input) 30%, transparent);
}
}
.dark\:inset-ring-input:where(.dark, .dark *) {
--tw-inset-ring-color: var(--input);
}
@media (hover: hover) {
.dark\:hover\:bg-destructive\/20:where(.dark, .dark *):hover {
background-color: var(--destructive);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:hover\:bg-destructive\/20:where(.dark, .dark *):hover {
background-color: color-mix(in oklab, var(--destructive) 20%, transparent);
}
}
.dark\:hover\:bg-destructive\/30:where(.dark, .dark *):hover {
background-color: var(--destructive);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:hover\:bg-destructive\/30:where(.dark, .dark *):hover {
background-color: color-mix(in oklab, var(--destructive) 30%, transparent);
}
}
.dark\:hover\:bg-input\/50:where(.dark, .dark *):hover {
background-color: var(--input);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:hover\:bg-input\/50:where(.dark, .dark *):hover {
background-color: color-mix(in oklab, var(--input) 50%, transparent);
}
}
.dark\:hover\:bg-muted\/50:where(.dark, .dark *):hover {
background-color: var(--muted);
}
@supports (color: color-mix(in lab, red, red)) {
.dark\:hover\:bg-muted\/50:where(.dark, .dark *):hover {
background-color: color-mix(in oklab, var(--muted) 50%, transparent);
}
}
}
.dark\:focus-visible\:ring-destructive\/70:where(.dark, .dark *):focus-visible {
--tw-ring-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--destructive) 70%, transparent);
}
}
.dark\:aria-invalid\:ring-destructive\/40:where(.dark, .dark *)[aria-invalid="true"] {
--tw-ring-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent);
}
}
.dark\:aria-invalid\:inset-ring-destructive\/50:where(.dark, .dark *)[aria-invalid="true"] {
--tw-inset-ring-color: var(--destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-inset-ring-color: color-mix(in oklab, var(--destructive) 50%, transparent);
}
}
@starting-style {
.starting\:opacity-0 {
opacity: 0%;
}
}
@media (forced-colors: active) {
.forced-colors\:border {
border-style: var(--tw-border-style);
border-width: var(--hairline);
}
.forced-colors\:border-0 {
border-style: var(--tw-border-style);
border-width: 0px;
}
}
@media (pointer: coarse) {
.pointer-coarse\:after\:absolute::after {
content: var(--tw-content);
position: absolute;
}
.pointer-coarse\:after\:-inset-1\.5::after {
content: var(--tw-content);
inset: calc(var(--spacing) * -1.5);
}
.pointer-coarse\:after\:-inset-2\.5::after {
content: var(--tw-content);
inset: calc(var(--spacing) * -2.5);
}
.pointer-coarse\:after\:inset-x-0::after {
content: var(--tw-content);
inset-inline: 0px;
}
.pointer-coarse\:after\:-inset-y-1\.5::after {
content: var(--tw-content);
inset-block: calc(var(--spacing) * -1.5);
}
.pointer-coarse\:after\:-inset-y-2\.5::after {
content: var(--tw-content);
inset-block: calc(var(--spacing) * -2.5);
}
:where([data-slot="button-group"]) .pointer-coarse\:in-data-\[slot\=button-group\]\:after\:hidden::after {
content: var(--tw-content);
display: none;
}
}
.\[\&_svg\]\:pointer-events-none svg {
pointer-events: none;
}
.\[\&_svg\]\:shrink-0 svg {
flex-shrink: 0;
}
.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*='size-']) {
width: calc(var(--spacing) * 3);
height: calc(var(--spacing) * 3);
}
.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*='size-']) {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-5 svg:not([class*='size-']) {
width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5);
}
@media (prefers-reduced-motion: no-preference) {
.motion-safe\:\[\&\>path\:first-child\]\:animate-button-draw-circle > path:first-child {
animation: var(--animate-button-draw-circle);
}
.motion-safe\:\[\&\>path\:first-child\]\:\[stroke-dasharray\:57\] > path:first-child {
stroke-dasharray: 57;
}
.motion-safe\:\[\&\>path\:first-child\]\:\[stroke-dashoffset\:57\] > path:first-child {
stroke-dashoffset: 57;
}
.motion-safe\:\[\&\>path\:last-child\]\:animate-button-draw-check > path:last-child {
animation: var(--animate-button-draw-check);
}
.motion-safe\:\[\&\>path\:last-child\]\:\[stroke-dasharray\:9\] > path:last-child {
stroke-dasharray: 9;
}
.motion-safe\:\[\&\>path\:last-child\]\:\[stroke-dashoffset\:9\] > path:last-child {
stroke-dashoffset: 9;
}
}
}
:root {
--background: #fff;
--foreground: #111;
--primary: #171717;
--primary-foreground: #fff;
--secondary: #eee;
--secondary-foreground: #171717;
--muted: #eee;
--muted-foreground: #666;
--accent: #eee;
--accent-foreground: #171717;
--border: #ddd;
--input: #ddd;
--ring: #777;
--destructive: #dc2626;
--destructive-foreground: #fff;
--popover: #fff;
--popover-foreground: #111;
--success: #15803d;
--success-foreground: #fff;
--radius: .625rem;
--hairline: 1px;
}
.dark {
--background: #090909;
--foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a3a3a3;
--accent: #262626;
--accent-foreground: #fafafa;
--border: #333;
--input: #333;
--popover: #171717;
--popover-foreground: #fafafa;
}
:root {
--hairline: 1px;
@media (min-resolution: 1.5dppx) {
--hairline: calc(1px / 1.5);
}
@media (min-resolution: 2dppx) {
--hairline: 0.5px;
}
@media (min-resolution: 3dppx) {
--hairline: calc(1px / 3);
}
@media (min-resolution: 4dppx) {
--hairline: 0.25px;
}
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.535 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.53 0.22 27.325);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.527 0.154 150);
--success-foreground: oklch(0.985 0 0);
--info: oklch(0.546 0.215 262);
--info-foreground: oklch(0.985 0 0);
--warning: oklch(0.555 0.163 49);
--warning-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.575 0.163 255.5);
--chart-2: oklch(0.621 0.128 163.1);
--chart-3: oklch(0.67 0.143 73.2);
--chart-4: oklch(0.433 0.167 283.6);
--chart-5: oklch(0.622 0.171 0.8);
--radius: 0.8rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--brand: oklch(0.703 0.198 43.4);
--brand-strong: oklch(0.553 0.174 38.4);
--brand-soft: oklch(0.967 0.019 55.2);
--brand-foreground: oklch(0.985 0 0);
--brand-ink: oklch(0.182 0.044 327.2);
--brand-navy: oklch(0.328 0.102 354.4);
--brand-ice: oklch(0.894 0.07 61.2);
--frame-x: clamp(1.5rem, 5.56vw, 5rem);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.205 0 0);
--success: oklch(0.792 0.209 151.7);
--success-foreground: oklch(0.205 0 0);
--info: oklch(0.707 0.165 254.6);
--info-foreground: oklch(0.205 0 0);
--warning: oklch(0.828 0.189 84.4);
--warning-foreground: oklch(0.205 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.622 0.161 255.1);
--chart-2: oklch(0.621 0.128 163.1);
--chart-3: oklch(0.67 0.143 73.2);
--chart-4: oklch(0.67 0.145 286.8);
--chart-5: oklch(0.622 0.171 0.8);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--brand: oklch(0.729 0.179 46.6);
--brand-strong: oklch(0.779 0.144 50.8);
--brand-soft: oklch(0.261 0.054 38.1);
--brand-foreground: oklch(0.985 0 0);
--brand-ink: oklch(0.155 0.032 332.7);
--brand-navy: oklch(0.296 0.091 353.8);
--brand-ice: oklch(0.894 0.07 61.2);
}
.dark .shiki, .dark .shiki span {
color: var(--shiki-dark);
}
@keyframes hero-reveal {
from {
opacity: 0;
filter: blur(10px);
transform: translateY(20%);
}
}
@keyframes hero-fade {
from {
opacity: 0;
}
}
@keyframes hero-glow {
0% {
opacity: 0;
}
20% {
opacity: 0.85;
}
}
@keyframes hero-shine {
0% {
opacity: 0;
background-position: 100% 0;
}
40% {
opacity: 1;
}
100% {
opacity: 0;
background-position: 0% 0;
}
}
@keyframes testimonial-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-duration {
syntax: "*";
inherits: false;
}
@property --tw-ease {
syntax: "*";
inherits: false;
}
@property --tw-translate-x {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-y {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-z {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-content {
syntax: "*";
initial-value: "";
inherits: false;
}
@keyframes button-state-in {
from {
opacity: 0;
transform: perspective(220px) rotateX(90deg) translateY(0.75rem);
}
to {
opacity: 1;
transform: perspective(220px) rotateX(0) translateY(0);
}
}
@keyframes button-state-out {
from {
opacity: 1;
transform: perspective(220px) rotateX(0) translateY(0);
}
50% {
opacity: 0;
}
to {
opacity: 0;
transform: perspective(220px) rotateX(-90deg) translateY(-0.75rem);
}
}
@keyframes spinner-rotate {
to {
rotate: 360deg;
}
}
@keyframes spinner-dash {
0% {
stroke-dasharray: 1 60;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 34 60;
stroke-dashoffset: -12;
}
100% {
stroke-dasharray: 34 60;
stroke-dashoffset: -56;
}
}
@keyframes spinner-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes button-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes button-shake {
0%, 100% {
translate: 0;
}
20% {
translate: -3px;
}
40% {
translate: 3px;
}
60% {
translate: -2px;
}
80% {
translate: 2px;
}
}
@keyframes button-state-in {
from {
opacity: 0;
transform: perspective(220px) rotateX(90deg) translateY(0.75rem);
}
to {
opacity: 1;
transform: perspective(220px) rotateX(0) translateY(0);
}
}
@keyframes button-state-out {
from {
opacity: 1;
transform: perspective(220px) rotateX(0) translateY(0);
}
50% {
opacity: 0;
}
to {
opacity: 0;
transform: perspective(220px) rotateX(-90deg) translateY(-0.75rem);
}
}
@keyframes spinner-rotate {
to {
rotate: 360deg;
}
}
@keyframes spinner-dash {
0% {
stroke-dasharray: 1 60;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 34 60;
stroke-dashoffset: -12;
}
100% {
stroke-dasharray: 34 60;
stroke-dashoffset: -56;
}
}
@keyframes spinner-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes button-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes button-shake {
0%, 100% {
translate: 0;
}
20% {
translate: -3px;
}
40% {
translate: 3px;
}
60% {
translate: -2px;
}
80% {
translate: 2px;
}
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-font-weight: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
--tw-duration: initial;
--tw-ease: initial;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-border-style: solid;
--tw-content: "";
}
}
}