For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Period Switcher

A chip with previous and next buttons around a label that slides up when the period changes.

Installation

$ pnpm dlx shadcn@latest add @cobalt/period-switcher

Usage

The block owns the index. Set disabled on the buttons at the ends of the range and pass the current label as the children of PeriodSwitcherLabel. When the label changes, the old text slides up and fades out while the new text rises in from below, whichever button was pressed. With reduced motion the text swaps at once.

Source

"use client";

import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Button } from "@/components/ui/button";

/**
 * A "‹ label ›" chip for stepping through periods such as weeks or months.
 * The block owns the index: it sets `disabled` at the ends of its range and
 * passes the new label, and the label swaps with a short upward slide.
 */
function PeriodSwitcher({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="period-switcher"
      role="group"
      className={cn(
        "flex h-8 shrink-0 items-center rounded-[10px] border border-[var(--border-subtle,var(--border))] bg-[var(--surface-card,var(--popover))] shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))]",
        className
      )}
      {...props}
    />
  );
}

function PeriodSwitcherPrevious({
  children,
  tooltip = "Previous period",
  ...props
}: React.ComponentProps<typeof Button>) {
  return (
    <Button
      data-slot="period-switcher-previous"
      variant="ghost-muted"
      size="icon-2xs"
      radius={6}
      tooltip={tooltip}
      {...props}
    >
      {children ?? <RiArrowLeftSLine aria-hidden className="size-4" />}
    </Button>
  );
}

function PeriodSwitcherNext({
  children,
  tooltip = "Next period",
  ...props
}: React.ComponentProps<typeof Button>) {
  return (
    <Button
      data-slot="period-switcher-next"
      variant="ghost-muted"
      size="icon-2xs"
      radius={6}
      tooltip={tooltip}
      {...props}
    >
      {children ?? <RiArrowRightSLine aria-hidden className="size-4" />}
    </Button>
  );
}

const swapMs = 200;

/**
 * The centred label. When `children` changes, the old text slides up and
 * fades out while the new text rises in from below, whichever button was
 * pressed. With reduced motion the text swaps at once.
 */
function PeriodSwitcherLabel({
  className,
  children,
  ...props
}: React.ComponentProps<"span">) {
  const [state, setState] = React.useState<{
    current: React.ReactNode;
    previous: React.ReactNode;
    swaps: number;
  }>({ current: children, previous: null, swaps: 0 });

  if (!Object.is(state.current, children)) {
    setState({
      current: children,
      previous: state.current,
      swaps: state.swaps + 1,
    });
  }

  React.useEffect(() => {
    if (state.swaps === 0) {
      return;
    }
    const timer = setTimeout(
      () => setState((s) => ({ ...s, previous: null })),
      swapMs
    );
    return () => clearTimeout(timer);
  }, [state.swaps]);

  const animating = state.previous !== null;

  return (
    <span
      data-slot="period-switcher-label"
      aria-live="polite"
      aria-atomic="true"
      className={cn(
        "relative flex flex-1 items-center justify-center px-1 text-center text-sm leading-5 font-medium whitespace-nowrap text-foreground",
        className
      )}
      {...props}
    >
      {animating && (
        <span
          key={`out-${state.swaps}`}
          aria-hidden
          className="absolute inset-0 flex animate-[period-switcher-out_200ms_ease-out_forwards] items-center justify-center motion-reduce:hidden"
        >
          {state.previous}
        </span>
      )}
      <span
        key={`in-${state.swaps}`}
        className={cn(
          animating &&
            "animate-[period-switcher-in_200ms_ease-out] motion-reduce:animate-none"
        )}
      >
        {state.current}
      </span>
    </span>
  );
}

export {
  PeriodSwitcher,
  PeriodSwitcherLabel,
  PeriodSwitcherNext,
  PeriodSwitcherPrevious,
};