Todos os componentes

Switchboard Card

Switchboard Card é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT cards

O que é

Switchboard Card é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Não precisa de pacotes além do que o projeto já tem

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Switchboard Card

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { useEffect, useMemo, useState } from "react";

export type LightState = "off" | "medium" | "high";

export interface SwitchboardCardProps {
  className?: string;
  columns?: number;
  gridPattern?: number[] | number[][]; // Direct grid pattern: flat array (columns*rows) or 2D array [rows][columns] where 0=off, 1=high
  href?: string;
  onButtonClick?: () => void;
  randomLights?: boolean; // If true, randomly activate some lights
  rows?: number;
  showButton?: boolean;
  subtitle: string;
  title: string;
  transitionDuration?: number; // in milliseconds (default: 200ms for smooth light transitions)
  variant?: "default" | "next";
}

function generateRandomPattern(
  totalLights: number,
  activeRatio = 0.15
): number[] {
  const activeCount = Math.floor(totalLights * activeRatio);
  const pattern: number[] = [];
  const used = new Set<number>();

  while (pattern.length < activeCount) {
    const index = Math.floor(Math.random() * totalLights);
    if (!used.has(index)) {
      used.add(index);
      pattern.push(index);
    }
  }

  return pattern.sort((a, b) => a - b);
}

function parse2DGridPattern(
  grid2D: number[][],
  rows: number,
  columns: number,
  totalLights: number
): number[] {
  const pattern: number[] = [];
  for (let row = 0; row < Math.min(grid2D.length, rows); row++) {
    const rowData = grid2D[row];
    if (Array.isArray(rowData)) {
      for (let col = 0; col < Math.min(rowData.length, columns); col++) {
        if (rowData[col] === 1) {
          const index = col + row * columns;
          if (index >= 0 && index < totalLights) {
            pattern.push(index);
          }
        }
      }
    }
  }
  return pattern;
}

function parseFlatGridPattern(
  flatPattern: number[],
  totalLights: number
): number[] {
  const pattern: number[] = [];
  for (let i = 0; i < Math.min(flatPattern.length, totalLights); i++) {
    if (flatPattern[i] === 1) {
      pattern.push(i);
    }
  }
  return pattern;
}

export default function SwitchboardCard({
  title,
  subtitle,
  columns = 18,
  rows = 5,
  gridPattern,
  randomLights = false,
  transitionDuration = 200,
  className,
  variant = "default",
  showButton: _showButton = true,
  href,
  onButtonClick,
}: SwitchboardCardProps) {
  const totalLights = columns * rows;
  const isRandomLightsMode = randomLights && !gridPattern;

  // Convert gridPattern to light indices
  // Priority: gridPattern > randomLights
  const basePattern = useMemo<number[]>(() => {
    if (gridPattern) {
      // Handle 2D array [rows][columns]
      const is2DArray =
        Array.isArray(gridPattern[0]) && typeof gridPattern[0] !== "number";
      if (is2DArray) {
        return parse2DGridPattern(
          gridPattern as number[][],
          rows,
          columns,
          totalLights
        );
      }
      // Handle flat array [columns*rows]
      return parseFlatGridPattern(gridPattern as number[], totalLights);
    }
    if (randomLights) {
      return generateRandomPattern(totalLights);
    }
    return [];
  }, [gridPattern, randomLights, columns, rows, totalLights]);

  // Animated light states
  // For random lights: all start off, will animate randomly through medium → high → medium → off
  // For word/pattern: pattern lights start high (stay high), rest off
  const [lightStates, setLightStates] = useState<LightState[]>(() => {
    const states: LightState[] = new Array(totalLights).fill("off");
    if (!isRandomLightsMode) {
      // For word/pattern, set pattern lights to high
      for (const index of basePattern) {
        if (index >= 0 && index < totalLights) {
          states[index] = "high";
        }
      }
    }
    // For random lights, all start off (will animate)
    return states;
  });

  // Animate lights - only for random lights mode (word/pattern stays high)
  useEffect(() => {
    // Word/pattern mode: no animation, lights stay high
    if (!isRandomLightsMode) {
      return;
    }

    const interval = setInterval(() => {
      setLightStates((prev) => {
        const next = [...prev];
        // For random lights: animate all lights randomly through off → high (via medium transition)
        // Randomly select a subset of lights to animate each tick
        const lightsToAnimate = Math.floor(totalLights * 0.2); // Animate ~20% of all lights per tick
        const allIndices = Array.from({ length: totalLights }, (_, i) => i);
        const shuffled = allIndices.sort(() => Math.random() - 0.5);

        for (let i = 0; i < Math.min(lightsToAnimate, shuffled.length); i++) {
          const index = shuffled[i];
          if (index >= 0 && index < totalLights) {
            const current = prev[index];
            // State transition logic for random lights: off → high (with medium as intermediate transition state)
            // Medium state is only used as a transition, not a final state
            if (current === "off") {
              // Start transition to high by going to medium first
              next[index] = "medium";
            } else if (current === "medium") {
              // Continue transition: medium → high
              next[index] = "high";
            } else if (current === "high") {
              // Turn off directly (no medium transition when turning off)
              next[index] = "off";
            }
          }
        }
        return next;
      });
    }, 200); // 0.2s interval to match transition duration

    return () => clearInterval(interval);
  }, [isRandomLightsMode, totalLights]);

  const isNextVariant = variant === "next";

  const CardContent = (
    <div
      className={cn(
        "relative flex h-[380px] w-full flex-col overflow-hidden rounded-xl border p-6 transition-[border-color,background-color] duration-150",
        isNextVariant
          ? "border-0 dark:border-0 dark:shadow-[inset_0_0_6px_rgba(255,255,255,0.1)]"
          : "border-border bg-background hover:border-foreground/20",
        className
      )}
      data-variant={isNextVariant ? "next" : undefined}
      style={
        isNextVariant
          ? ({
              background:
                "linear-gradient(110deg, oklch(0.65 0 0) 0.06%, oklch(0.54 0 0) 100%)",
            } as React.CSSProperties)
          : undefined
      }
    >
      {/* Illustration/Switchboard */}
      <div
        className="flex h-full w-full items-center justify-center"
        data-illustration="true"
      >
        <div
          className="grid h-full w-full"
          style={{
            gap: `min(1px, calc(100% / ${columns} / 10))`,
            gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
            gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
            maxHeight: "81px",
            transitionDuration: `${transitionDuration}ms`,
          }}
        >
          {lightStates.map((state, index) => (
            <LightBulb
              // biome-ignore lint/suspicious/noArrayIndexKey: Light grid position is stable and never reorders
              key={`light-${index}`}
              state={state}
              transitionDuration={transitionDuration}
            />
          ))}
        </div>
      </div>

      {/* Title */}
      <div className="mt-4 flex flex-row items-center justify-start gap-2 text-left">
        <h3
          className={cn(
            "text-left font-semibold leading-8 tracking-[-0.04em]",
            isNextVariant ? "mt-4 text-2xl" : "text-foreground text-xl"
          )}
          data-title="true"
          style={
            isNextVariant
              ? ({ color: "oklch(0.95 0 0)" } as React.CSSProperties)
              : undefined
          }
        >
          {title}
        </h3>
      </div>

      {/* Subtitle */}
      <p
        className={cn(
          "text-left text-sm leading-relaxed tracking-[-0.01em]",
          isNextVariant
            ? "mt-1 max-w-[80%] text-base"
            : "mt-1 text-foreground/70"
        )}
        data-subtitle="true"
        style={
          isNextVariant
            ? ({ color: "oklch(0.9 0 0)" } as React.CSSProperties)
            : undefined
        }
      >
        {subtitle}
      </p>
    </div>
  );

  if (href) {
    return (
      <a
        aria-label={title}
        className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
        href={href}
      >
        {CardContent}
      </a>
    );
  }

  if (onButtonClick) {
    return (
      <button
        aria-label={title}
        className="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
        onClick={onButtonClick}
        type="button"
      >
        {CardContent}
      </button>
    );
  }

  return CardContent;
}

function LightBulb({
  state,
  transitionDuration,
}: {
  state: LightState;
  transitionDuration: number;
}) {
  const transitionStyle = {
    transition: `opacity ${transitionDuration}ms ease-out, transform ${transitionDuration}ms ease-out`,
  };

  return (
    <div
      className={cn(
        "relative rounded-full",
        state === "off" && "bg-foreground/50",
        state === "high" ? "h-[2px] w-[2px]" : "h-px w-px"
      )}
      data-state={state}
      style={{
        ...transitionStyle,
        transform: state === "off" ? "unset" : "scale(1)",
      }}
    >
      {/* Glow effect for medium state (::before equivalent) */}
      {state === "medium" && (
        <div
          className="absolute inset-0 rounded-full"
          style={{
            ...transitionStyle,
            backgroundColor: "var(--color-brand)",
            boxShadow:
              "0 0 2px 1px color-mix(in oklch, var(--color-brand), transparent 60%), 0 0 4px 1.5px color-mix(in oklch, var(--color-brand), transparent 75%)",
            opacity: 1,
          }}
        />
      )}

      {/* Glow effect for high state (::after equivalent) */}
      {state === "high" && (
        <div
          className="absolute inset-0 rounded-full"
          style={{
            ...transitionStyle,
            backgroundColor: "var(--color-brand)",
            boxShadow:
              "0 0 2px 1px color-mix(in oklch, var(--color-brand), transparent 40%), 0 0 4px 1.5px color-mix(in oklch, var(--color-brand), transparent 65%), 0 0 6px 2px color-mix(in oklch, var(--color-brand), transparent 80%)",
            opacity: 1,
          }}
        />
      )}
    </div>
  );
}

Componentes parecidos