Progress Bar
Linear progress indicator.
Installation
$
npx @309-thingspire/ui@latest add progress-barUsage
import { ProgressBar } from "@/components/progress-bar/progress-bar"<ProgressBar />Examples
Live preview rendered from ProgressBar.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React from 'react';
import { colors, radius, shadows, spacing, typography } from '../../style-tokens';
import { HelperIconDefault, HelperIconDestructive, TailIconDefault } from './ProgressBar.assets';
import type {
ProgressBarColor,
ProgressBarDirection,
ProgressBarInteractionState,
ProgressBarProps,
ProgressBarSize,
ProgressBarTarget,
} from './ProgressBar.types';
type TypographyToken = {
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
};
const palette = colors.primitive.palette;
const textBase = colors.semantic.theme.text.base;
const textStatus = colors.semantic.theme.text.status;
const PROGRESS_MIN = spacing.scale['0'];
const PROGRESS_MAX = spacing.scale['10'] * spacing.scale['10'];
const PROGRESS_DEFAULT = PROGRESS_MAX / spacing.scale['2'];
const SIZE_TO_LINE_HEIGHT: Record<ProgressBarSize, number> = {
sm: spacing.scale['4'],
md: spacing.scale['8'],
lg: spacing.scale['12'],
};
const COLOR_TO_FILL: Record<ProgressBarColor, string> = {
green: palette.green['8'],
blue: palette.blue['8'],
red: palette.red['8'],
orange: palette.orange['8'],
purple: palette.purple['8'],
};
function toTypographyStyle(token: TypographyToken) {
return {
fontFamily: token.fontFamily,
fontSize: token.fontSize,
fontWeight: token.fontWeight,
lineHeight: `${token.lineHeight}px`,
letterSpacing: `${token.letterSpacing}px`,
};
}
function clampProgress(value: number): number {
return Math.max(PROGRESS_MIN, Math.min(PROGRESS_MAX, value));
}
function resolveColor(target: ProgressBarTarget, color: ProgressBarColor | undefined): ProgressBarColor {
if (color) {
return color;
}
return target === 'destructive' ? 'red' : 'green';
}
function resolveShowProgressState(
showProgressState: boolean | undefined,
target: ProgressBarTarget,
): boolean {
if (typeof showProgressState === 'boolean') {
return showProgressState;
}
return target === 'default';
}
function resolveShowHelper(
showHelper: boolean | undefined,
direction: ProgressBarDirection,
): boolean {
if (typeof showHelper === 'boolean') {
return showHelper;
}
return direction === 'vertical';
}
function withFocusRing(interactionState: ProgressBarInteractionState): string {
if (interactionState !== 'focus') {
return 'none';
}
return shadows.focusRing.light.css;
}
function TailIcon({ disabled }: { disabled: boolean }) {
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['16'],
height: spacing.scale['16'],
display: 'block',
opacity: disabled ? 0.5 : 1,
userSelect: 'none',
pointerEvents: 'none',
}}
>
<TailIconDefault />
</span>
);
}
function HelperIcon({ destructive, disabled }: { destructive: boolean; disabled: boolean }) {
const Component = destructive ? HelperIconDestructive : HelperIconDefault;
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['16'],
height: spacing.scale['16'],
display: 'block',
opacity: disabled ? 0.5 : 1,
userSelect: 'none',
pointerEvents: 'none',
}}
>
<Component />
</span>
);
}
function ShimmerOverlay({
enabled,
disabled,
}: {
enabled: boolean;
disabled: boolean;
}) {
if (!enabled || disabled) {
return null;
}
return (
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: spacing.scale['0'],
backgroundImage: `linear-gradient(90deg, ${palette.base.transparent} 0%, ${palette.white['8']} 50%, ${palette.base.transparent} 100%)`,
backgroundSize: `${spacing.scale['40']}px 100%`,
animationName: 'progress-bar-shimmer',
animationDuration: `${spacing.scale['160'] * spacing.scale['10']}ms`,
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
}}
/>
);
}
export function ProgressBar({
direction = 'vertical',
target = 'default',
size = 'md',
color,
interactionState = 'default',
progressValue = PROGRESS_DEFAULT,
width = spacing.scale['400'],
label = 'Label',
optionalLabel = '(optional)',
helperText = 'Helper text',
showLabel = true,
showOptionalLabel = true,
showProgressState,
showTailIcon,
showHelper,
shimmering = false,
valueText,
className,
style,
...props
}: ProgressBarProps) {
const resolvedColor = resolveColor(target, color);
const isDestructive = target === 'destructive';
const disabled = interactionState === 'disabled';
const lineHeight = SIZE_TO_LINE_HEIGHT[size];
const progress = clampProgress(progressValue);
const fillColor = disabled ? palette.gray['4'] : COLOR_TO_FILL[resolvedColor];
const resolvedShowProgressState = resolveShowProgressState(showProgressState, target);
const resolvedShowTailIcon = typeof showTailIcon === 'boolean' ? showTailIcon : resolvedShowProgressState;
const resolvedShowHelper = resolveShowHelper(showHelper, direction);
const helperColor = isDestructive ? textStatus.destructive : textBase.staticDarkTertiary;
const optionalColor = disabled ? textBase.staticDarkQuaternary : textBase.staticDarkTertiary;
const primaryTextColor = disabled ? textBase.staticDarkQuaternary : textBase.staticDark;
const progressText = valueText ?? `${Math.round(progress)}%`;
return (
<div
role="progressbar"
aria-valuemin={PROGRESS_MIN}
aria-valuemax={PROGRESS_MAX}
aria-valuenow={Math.round(progress)}
aria-label={label}
className={className}
style={{
width,
display: 'inline-flex',
flexDirection: direction === 'vertical' ? 'column' : 'row',
alignItems: direction === 'vertical' ? 'flex-start' : 'center',
gap: direction === 'vertical' ? spacing.scale['8'] : spacing.scale['12'],
boxSizing: 'border-box',
boxShadow: withFocusRing(interactionState),
...style,
}}
{...props}
>
<style>{`@keyframes progress-bar-shimmer {0% {background-position: -${spacing.scale['40']}px 0;} 100% {background-position: ${spacing.scale['40']}px 0;}}`}</style>
{showLabel ? (
<div
style={{
width: direction === 'vertical' ? '100%' : 'auto',
minHeight: spacing.scale['24'],
display: 'inline-flex',
alignItems: 'flex-start',
justifyContent: direction === 'vertical' ? 'space-between' : 'flex-start',
gap: direction === 'vertical' ? spacing.scale['8'] : spacing.scale['0'],
flexShrink: 0,
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: spacing.scale['4'],
paddingInline: spacing.scale['0'],
paddingBlock: spacing.scale['2'],
}}
>
<span
style={{
color: primaryTextColor,
whiteSpace: 'nowrap',
...toTypographyStyle(typography.scale.captionL.medium),
}}
>
{label}
</span>
{showOptionalLabel ? (
<span
style={{
color: optionalColor,
whiteSpace: 'nowrap',
...toTypographyStyle(typography.scale.captionL.medium),
}}
>
{optionalLabel}
</span>
) : null}
</span>
{direction === 'vertical' && resolvedShowProgressState ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: spacing.scale['4'],
paddingInline: spacing.scale['0'],
paddingBlock: spacing.scale['2'],
}}
>
<span
style={{
color: primaryTextColor,
whiteSpace: 'nowrap',
...toTypographyStyle(typography.scale.captionL.regular),
}}
>
{progressText}
</span>
{resolvedShowTailIcon ? <TailIcon disabled={disabled} /> : null}
</span>
) : null}
</div>
) : null}
{direction === 'horizontal' ? (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: spacing.scale['12'],
width: showLabel ? 'auto' : '100%',
flex: showLabel ? undefined : '1 0 0',
minWidth: showLabel ? undefined : spacing.scale['0'],
}}
>
<div
style={{
height: lineHeight,
borderRadius: radius.scale.full,
backgroundColor: palette.gray['2'],
overflow: 'hidden',
position: 'relative',
flex: '1 0 0',
minWidth: spacing.scale['0'],
}}
>
<span
style={{
position: 'absolute',
insetBlock: spacing.scale['0'],
insetInlineStart: spacing.scale['0'],
width: `${progress}%`,
borderRadius: radius.scale.full,
backgroundColor: fillColor,
transitionProperty: 'width',
transitionDuration: `${spacing.scale['160']}ms`,
transitionTimingFunction: 'linear',
overflow: 'hidden',
}}
>
<ShimmerOverlay enabled={shimmering} disabled={disabled} />
</span>
</div>
{resolvedShowProgressState ? (
<span
style={{
minHeight: spacing.scale['24'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: spacing.scale['4'],
paddingInline: spacing.scale['0'],
paddingBlock: spacing.scale['2'],
flexShrink: 0,
}}
>
<span
style={{
color: primaryTextColor,
whiteSpace: 'nowrap',
...toTypographyStyle(typography.scale.captionL.regular),
}}
>
{progressText}
</span>
{resolvedShowTailIcon ? <TailIcon disabled={disabled} /> : null}
</span>
) : null}
</div>
) : (
<div
style={{
width: '100%',
height: lineHeight,
borderRadius: radius.scale.full,
backgroundColor: palette.gray['2'],
overflow: 'hidden',
position: 'relative',
}}
>
<span
style={{
position: 'absolute',
insetBlock: spacing.scale['0'],
insetInlineStart: spacing.scale['0'],
width: `${progress}%`,
borderRadius: radius.scale.full,
backgroundColor: fillColor,
transitionProperty: 'width',
transitionDuration: `${spacing.scale['160']}ms`,
transitionTimingFunction: 'linear',
overflow: 'hidden',
}}
>
<ShimmerOverlay enabled={shimmering} disabled={disabled} />
</span>
</div>
)}
{resolvedShowHelper ? (
<span
style={{
width: '100%',
display: 'inline-flex',
alignItems: 'center',
gap: spacing.scale['4'],
paddingInline: spacing.scale['0'],
paddingBlock: spacing.scale['2'],
}}
>
<HelperIcon destructive={isDestructive} disabled={disabled} />
<span
style={{
color: disabled ? textBase.staticDarkQuaternary : helperColor,
whiteSpace: 'nowrap',
...toTypographyStyle(typography.scale.captionL.regular),
}}
>
{helperText}
</span>
</span>
) : null}
</div>
);
}
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)