Compare commits

...

4 Commits

Author SHA1 Message Date
openhands 2c55829a39 fix: separate rotation and arc length animations for dynamic spinner 2025-07-09 14:25:47 +00:00
openhands 2c3494c71e feat: add dynamic variant to Spinner component 2025-07-09 14:21:58 +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 198 additions and 0 deletions
@@ -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>
),
};
@@ -0,0 +1,97 @@
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}>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-grey-970"
strokeWidth={STROKE_WIDTH}
/>
{determinate ? (
<g>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-primary-500 animate-determinate-spinner"
strokeWidth={STROKE_WIDTH}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
/>
</g>
) : variant === "simple" ? (
<g className="animate-indeterminate-spinner origin-center">
<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>
) : (
<g className="animate-spinner-rotate origin-center">
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-primary-500 animate-arc-length"
strokeWidth={STROKE_WIDTH}
strokeDasharray={circumference}
strokeDashoffset={circumference * 0.75}
strokeLinecap="round"
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
/>
</g>
)}
</svg>
);
};
+44
View File
@@ -0,0 +1,44 @@
@layer utilities {
@keyframes spinner-rotate {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes arc-length-change {
0% {
stroke-dashoffset: 85%;
}
25% {
stroke-dashoffset: 25%;
}
50% {
stroke-dashoffset: 10%;
}
75% {
stroke-dashoffset: 25%;
}
100% {
stroke-dashoffset: 85%;
}
}
.animate-indeterminate-spinner {
animation: spinner-rotate 2s linear infinite;
}
.animate-spinner-rotate {
animation: spinner-rotate 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.animate-arc-length {
animation: arc-length-change 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.animate-determinate-spinner {
transition: stroke-dashoffset 0.3s ease;
}
}