Actions

Hero CTA actions: button stack / email input + button / Monthly-Annual tabs (3 types × 2 devices).

Installation

$npx @309-thingspire/ui@latest add actions

Usage

import { Actions } from "@/components/actions/actions"
<Actions />

Examples

Live preview rendered from Actions.preview.tsx. Switch to the Code tab to view the underlying component source.

Loading preview…
'use client';

import React, { useState } from 'react';

import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import { Button } from '../Button/Button';
import { IconMailLine } from '../icons';

import type {
  ActionsProps,
  ActionsTabItem,
} from './Actions.types';

const palette = colors.primitive.palette;

const captionLMedium = typography.scale.captionL.medium;
const captionLRegular = typography.scale.captionL.regular;
const bodySMedium = typography.scale.bodyS.medium;

const FIELD_TEXT_STYLE: React.CSSProperties = {
  fontFamily: captionLRegular.fontFamily,
  fontSize: captionLRegular.fontSize,
  fontWeight: captionLRegular.fontWeight,
  lineHeight: `${captionLRegular.lineHeight}px`,
  letterSpacing: `${captionLRegular.letterSpacing}px`,
  color: palette.gray['13'],
  margin: 0,
};

const TAB_TEXT_STYLE: React.CSSProperties = {
  fontFamily: bodySMedium.fontFamily,
  fontSize: bodySMedium.fontSize,
  fontWeight: bodySMedium.fontWeight,
  lineHeight: `${bodySMedium.lineHeight}px`,
  letterSpacing: `${bodySMedium.letterSpacing}px`,
  margin: 0,
  whiteSpace: 'nowrap',
};

const TAB_BADGE_TEXT_STYLE: React.CSSProperties = {
  fontFamily: captionLMedium.fontFamily,
  fontSize: captionLMedium.fontSize,
  fontWeight: captionLMedium.fontWeight,
  lineHeight: `${captionLMedium.lineHeight}px`,
  letterSpacing: `${captionLMedium.letterSpacing}px`,
  color: palette.gray['13'],
  margin: 0,
  whiteSpace: 'nowrap',
};

const DEFAULT_TABS: ActionsTabItem[] = [
  { id: 'monthly', label: 'Monthly' },
  { id: 'annual', label: 'Annual', badge: 'Save 25%' },
];

const DESKTOP_BUTTON_WIDTH = spacing.scale['390'];
const MOBILE_WIDTH = spacing.scale['320'] + spacing.scale['40'];
const TABS_WIDTH = spacing.scale['390'];

function ButtonActions({
  primaryLabel = 'Get started',
  secondaryLabel = 'Try Blank free',
  isMobile,
  onPrimaryClick,
  onSecondaryClick,
}: {
  primaryLabel?: React.ReactNode;
  secondaryLabel?: React.ReactNode;
  isMobile: boolean;
  onPrimaryClick?: ActionsProps['onPrimaryClick'];
  onSecondaryClick?: ActionsProps['onSecondaryClick'];
}) {
  return (
    <>
      <Button
        variant="primary"
        size="md"
        shape="rounded"
        fullWidth={isMobile}
        onClick={onPrimaryClick}
        style={{ borderRadius: radius.scale.xl }}
      >
        {primaryLabel}
      </Button>
      <Button
        variant="secondary"
        size="md"
        shape="rounded"
        fullWidth={isMobile}
        onClick={onSecondaryClick}
        style={{ borderRadius: radius.scale.xl }}
      >
        {secondaryLabel}
      </Button>
    </>
  );
}

function InputActions({
  isMobile,
  placeholder,
  value,
  onChange,
  name,
  inputType = 'email',
  showLeadIcon = true,
  leadIcon,
  actionLabel = 'Get early access',
  onAction,
}: {
  isMobile: boolean;
  placeholder?: string;
  value?: string;
  onChange?: (value: string) => void;
  name?: string;
  inputType?: 'email' | 'text';
  showLeadIcon?: boolean;
  leadIcon?: React.ReactNode;
  actionLabel?: React.ReactNode;
  onAction?: ActionsProps['onInputAction'];
}) {
  const [focused, setFocused] = useState(false);

  return (
    <>
      <div
        style={{
          flex: isMobile ? undefined : '1 0 0',
          width: isMobile ? '100%' : undefined,
          minWidth: spacing.scale['144'],
          display: 'flex',
          alignItems: 'center',
          gap: spacing.scale['4'],
          paddingInline: spacing.scale['12'],
          paddingBlock: spacing.scale['10'],
          backgroundColor: palette.base.white,
          borderStyle: 'solid',
          borderWidth: border.width['1'],
          borderColor: focused ? palette.purple['8'] : palette.gray['3'],
          borderRadius: radius.scale.xl,
          boxShadow: focused ? shadows.focusRing.light.css : '0px 1px 2px 0px rgba(20,21,26,0.05)',
          boxSizing: 'border-box',
        }}
      >
        {showLeadIcon ? (
          <span
            aria-hidden="true"
            style={{
              display: 'inline-flex',
              alignItems: 'center',
              flexShrink: 0,
              color: palette.gray['7a'],
            }}
          >
            {leadIcon ?? (
              <IconMailLine
                aria-hidden
                style={{ width: spacing.scale['20'], height: spacing.scale['20'], display: 'block' }}
              />
            )}
          </span>
        ) : null}
        <input
          type={inputType}
          name={name}
          value={value}
          placeholder={placeholder ?? 'Enter your email'}
          onChange={onChange ? (event) => onChange(event.target.value) : undefined}
          onFocus={() => setFocused(true)}
          onBlur={() => setFocused(false)}
          style={{
            ...FIELD_TEXT_STYLE,
            flex: '1 0 0',
            minWidth: 0,
            border: 'none',
            outline: 'none',
            background: 'transparent',
            paddingInline: spacing.scale['4'],
          }}
        />
      </div>

      <Button
        variant="primary"
        size="md"
        shape="rounded"
        fullWidth={isMobile}
        onClick={onAction}
        style={{ borderRadius: radius.scale.xl, flexShrink: 0 }}
      >
        {actionLabel}
      </Button>
    </>
  );
}

function TabsActions({
  tabs = DEFAULT_TABS,
  activeTabId,
  defaultActiveTabId,
  onTabChange,
}: {
  tabs?: ActionsTabItem[];
  activeTabId?: string;
  defaultActiveTabId?: string;
  onTabChange?: (id: string) => void;
}) {
  const isControlled = typeof activeTabId === 'string';
  const [internalActive, setInternalActive] = useState(
    () => defaultActiveTabId ?? tabs[1]?.id ?? tabs[0]?.id ?? '',
  );
  const active = isControlled ? activeTabId! : internalActive;

  const handleClick = (id: string) => {
    if (!isControlled) {
      setInternalActive(id);
    }
    onTabChange?.(id);
  };

  return (
    <div
      role="tablist"
      style={{
        flex: '1 0 0',
        minWidth: 0,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        gap: spacing.scale['2'],
        padding: spacing.scale['2'],
        backgroundColor: palette.gray['2'],
        borderRadius: radius.scale.xl,
      }}
    >
      {tabs.map((tab, index) => {
        const id = tab.id ?? `tab-${index}`;
        const isActive = active === id;
        return (
          <button
            key={id}
            type="button"
            role="tab"
            aria-selected={isActive}
            onClick={() => handleClick(id)}
            style={{
              flex: '1 0 0',
              minWidth: 0,
              display: 'inline-flex',
              alignItems: 'center',
              justifyContent: 'center',
              gap: spacing.scale['4'],
              paddingInline: spacing.scale['12'],
              paddingBlock: spacing.scale['10'],
              borderRadius: radius.scale.lg,
              borderStyle: 'solid',
              borderWidth: isActive ? border.width['1'] : border.width['0'],
              borderColor: isActive ? palette.gray['3'] : 'transparent',
              backgroundColor: isActive ? palette.base.white : 'transparent',
              boxShadow: isActive ? '0px 1px 2px 0px rgba(20,21,26,0.05)' : 'none',
              cursor: 'pointer',
              appearance: 'none',
              outline: 'none',
            }}
          >
            <span
              style={{
                ...TAB_TEXT_STYLE,
                color: isActive ? palette.gray['13'] : palette.gray['9a'],
              }}
            >
              {tab.label}
            </span>
            {tab.badge != null ? (
              <span
                style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  paddingInline: spacing.scale['8'],
                  paddingBlock: spacing.scale['2'],
                  backgroundColor: palette.gray['1a'],
                  borderRadius: radius.scale.md,
                }}
              >
                <span style={TAB_BADGE_TEXT_STYLE}>{tab.badge}</span>
              </span>
            ) : null}
          </button>
        );
      })}
    </div>
  );
}

export function Actions({
  type = 'button',
  device = 'desktop',
  primaryLabel,
  secondaryLabel,
  onPrimaryClick,
  onSecondaryClick,
  inputPlaceholder,
  inputValue,
  inputName,
  inputType = 'email',
  inputLeadIcon,
  showInputLeadIcon = true,
  inputActionLabel,
  onInputChange,
  onInputAction,
  tabs,
  activeTabId,
  defaultActiveTabId,
  onTabChange,
  className,
  style,
}: ActionsProps) {
  const isMobile = device === 'mobile';
  const isInput = type === 'input';
  const isTabs = type === 'tabs';

  const containerStyle: React.CSSProperties = {
    boxSizing: 'border-box',
    display: 'flex',
    alignItems: isMobile && !isTabs ? 'stretch' : 'flex-start',
    flexDirection: isMobile && !isTabs ? 'column' : 'row',
    gap: isMobile && !isTabs ? spacing.scale['12'] : isInput ? spacing.scale['8'] : spacing.scale['16'],
    justifyContent: isInput && !isMobile ? 'center' : isInput ? 'flex-start' : isTabs ? 'flex-start' : 'flex-start',
    width: isTabs
      ? TABS_WIDTH
      : isMobile
        ? MOBILE_WIDTH
        : isInput
          ? DESKTOP_BUTTON_WIDTH
          : undefined,
    maxWidth: isTabs || (isInput && !isMobile) ? spacing.scale['390'] : undefined,
    ...style,
  };

  return (
    <div className={className} style={containerStyle}>
      {type === 'button' ? (
        <ButtonActions
          isMobile={isMobile}
          primaryLabel={primaryLabel ?? 'Get started'}
          secondaryLabel={secondaryLabel ?? 'Try Blank free'}
          onPrimaryClick={onPrimaryClick}
          onSecondaryClick={onSecondaryClick}
        />
      ) : null}

      {isInput ? (
        <InputActions
          isMobile={isMobile}
          placeholder={inputPlaceholder}
          value={inputValue}
          name={inputName}
          inputType={inputType}
          showLeadIcon={showInputLeadIcon}
          leadIcon={inputLeadIcon}
          actionLabel={inputActionLabel ?? 'Get early access'}
          onChange={onInputChange}
          onAction={onInputAction}
        />
      ) : null}

      {isTabs ? (
        <TabsActions
          tabs={tabs}
          activeTabId={activeTabId}
          defaultActiveTabId={defaultActiveTabId}
          onTabChange={onTabChange}
        />
      ) : null}
    </div>
  );
}

export default Actions;

API Reference

Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)