Compare commits

...

3 Commits

Author SHA1 Message Date
openhands
92dee2ddf9 feat: implement clean dynamic spinner variant with inline styles 2025-07-09 14:30:12 +00:00
Mislav Lukach
fe059e7fc7 Merge branch 'main' into feature/spinner 2025-07-09 15:09:17 +02:00
mislavlukach
62c948fc91 feat(ui): spinner component 2025-07-07 19:21:17 +02:00
3 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Spinner } from "./Spinner";
import { useEffect, useState } from "react";
const meta = {
title: "Components/Spinner",
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
const DeterminateSpinner = () => {
const [percentage, setPercentage] = useState(10);
useEffect(() => {
setTimeout(() => setPercentage(Math.min(100, percentage + 30)), 600);
}, [percentage]);
return <Spinner determinate value={percentage} />;
};
export const Determinate: Story = {
render: () => <DeterminateSpinner />,
};
export const IndeterminateSimple: Story = {
render: () => <Spinner variant="simple" />,
name: "Indeterminate (Simple)",
};
export const IndeterminateDynamic: Story = {
render: () => <Spinner variant="dynamic" />,
name: "Indeterminate (Dynamic)",
};
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 items-center">
<div className="flex gap-4 items-center">
<Spinner variant="simple" />
<span>Simple Indeterminate</span>
</div>
<div className="flex gap-4 items-center">
<Spinner variant="dynamic" />
<span>Dynamic Indeterminate</span>
</div>
<div className="flex gap-4 items-center">
<DeterminateSpinner />
<span>Determinate</span>
</div>
</div>
),
};

View File

@@ -0,0 +1,116 @@
import { useMemo } from "react";
import type { HTMLProps } from "../../shared/types";
import { cn } from "../../shared/utils/cn";
import "./index.css";
type BaseSpinnerProps = HTMLProps<"svg">;
export type DeterminateSpinnerProps = BaseSpinnerProps & {
determinate: true;
value: number;
variant?: never;
};
export type IndeterminateSpinnerProps = BaseSpinnerProps & {
determinate?: false | null | undefined;
value?: never;
variant?: "simple" | "dynamic";
};
export type SpinnerProps = DeterminateSpinnerProps | IndeterminateSpinnerProps;
const SIZE = 48;
const STROKE_WIDTH = 6;
const radius = (SIZE - STROKE_WIDTH) / 2;
const circumference = 2 * Math.PI * radius;
export const Spinner = ({
value = 10,
determinate = false,
variant = "simple",
className,
...props
}: SpinnerProps) => {
const offset = useMemo(
() => circumference - (value / 100) * circumference,
[value]
);
return (
<svg width={SIZE} height={SIZE} className={className} {...props}>
{/* Background circle */}
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-grey-970"
strokeWidth={STROKE_WIDTH}
/>
{determinate ? (
// Determinate spinner
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-primary-500"
strokeWidth={STROKE_WIDTH}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
style={{
transition: "stroke-dashoffset 0.3s ease"
}}
/>
) : variant === "simple" ? (
// Simple indeterminate spinner
<g
style={{
transformOrigin: "center",
animation: "2s linear infinite spinner-simple-rotate"
}}
>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-primary-500"
strokeWidth={STROKE_WIDTH}
strokeDasharray={circumference}
strokeDashoffset={circumference * 0.75}
strokeLinecap="round"
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
/>
</g>
) : (
// Dynamic indeterminate spinner
<g
style={{
transformOrigin: "center",
animation: "1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite spinner-dynamic-rotate"
}}
>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-primary-500"
strokeWidth={STROKE_WIDTH}
strokeDasharray={circumference}
strokeDashoffset={circumference * 0.75}
strokeLinecap="round"
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
style={{
animation: "1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite spinner-dynamic-arc"
}}
/>
</g>
)}
</svg>
);
};

View File

@@ -0,0 +1,35 @@
@keyframes spinner-simple-rotate {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes spinner-dynamic-rotate {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes spinner-dynamic-arc {
0% {
stroke-dashoffset: 85%;
}
25% {
stroke-dashoffset: 25%;
}
50% {
stroke-dashoffset: 10%;
}
75% {
stroke-dashoffset: 25%;
}
100% {
stroke-dashoffset: 85%;
}
}