From 6ef46773029e86a08bbcf8141114d386f36bfac9 Mon Sep 17 00:00:00 2001 From: Ted Palmer Date: Fri, 6 Mar 2026 12:29:22 -0500 Subject: [PATCH 1/7] Add onHapticFeedback callback to RelayKitProvider --- .../providers/RelayKitProviderWrapper.tsx | 15 +++- demo/package.json | 3 +- .../components/common/CustomAddressModal.tsx | 8 ++- .../common/SlippageToleranceConfig.tsx | 3 + .../common/TokenSelector/ChainFilter.tsx | 8 +-- .../common/TokenSelector/ChainFilterRow.tsx | 3 + .../TokenSelector/ChainFilterSidebar.tsx | 5 ++ .../TokenSelector/MobileChainSelector.tsx | 10 +-- .../common/TokenSelector/PaymentMethod.tsx | 4 ++ .../common/TokenSelector/TokenSelector.tsx | 4 ++ .../TransactionModal/DepositAddressModal.tsx | 3 + .../DepositAddressModalRenderer.tsx | 3 + .../TransactionModal/TransactionModal.tsx | 4 ++ .../common/UnverifiedTokenModal.tsx | 3 + .../OnrampWidget/modals/OnrampModal.tsx | 4 ++ .../modals/steps/OnrampConfirmingStep.tsx | 3 + .../widgets/OnrampWidget/widget/index.tsx | 7 +- .../ui/src/components/widgets/SwapButton.tsx | 3 + .../components/widgets/SwapWidget/index.tsx | 11 ++- .../components/widgets/SwapWidgetRenderer.tsx | 7 +- packages/ui/src/index.ts | 1 + .../ui/src/providers/RelayKitProvider.tsx | 72 ++++++++++++++++++- pnpm-lock.yaml | 25 +++++++ 23 files changed, 193 insertions(+), 16 deletions(-) diff --git a/demo/components/providers/RelayKitProviderWrapper.tsx b/demo/components/providers/RelayKitProviderWrapper.tsx index 586a7e79..ab8d6a79 100644 --- a/demo/components/providers/RelayKitProviderWrapper.tsx +++ b/demo/components/providers/RelayKitProviderWrapper.tsx @@ -4,10 +4,12 @@ import { RelayChain } from '@relayprotocol/relay-sdk' import { RelayKitProvider } from '@relayprotocol/relay-kit-ui' +import type { HapticEventType } from '@relayprotocol/relay-kit-ui' import { useTheme } from 'next-themes' import { useRouter } from 'next/router' -import { FC, ReactNode, useMemo } from 'react' +import { FC, ReactNode, useCallback, useMemo } from 'react' import { useCustomize } from 'context/customizeContext' +import { useWebHaptics } from 'web-haptics/react' const DEFAULT_APP_FEES = [ { @@ -30,6 +32,16 @@ export const RelayKitProviderWrapper: FC<{ const router = useRouter() const { themeOverrides, websocketsEnabled } = useCustomize() const appFeesEnabled = router.query.appFees === 'true' + const { trigger } = useWebHaptics({ debug: true }) + + // web-haptics presets match our HapticEventType names exactly + const onHapticEvent = useCallback( + (type: HapticEventType) => { + console.log(`[haptic] ${type}`) + trigger(type) + }, + [trigger] + ) const mergedTheme = useMemo( () => ({ @@ -62,6 +74,7 @@ export const RelayKitProviderWrapper: FC<{ }, secureBaseUrl: process.env.NEXT_PUBLIC_RELAY_SECURE_API_URL, appFees: appFeesEnabled ? DEFAULT_APP_FEES : undefined, + onHapticEvent, logger: (message, level) => { window.dispatchEvent( new CustomEvent('relay-kit-logger', { diff --git a/demo/package.json b/demo/package.json index 72f2750a..df9d1b75 100644 --- a/demo/package.json +++ b/demo/package.json @@ -47,7 +47,8 @@ "tronweb": "^6.0.4", "usehooks-ts": "^3.1.0", "viem": ">=2.26.0", - "wagmi": "^2.15.6" + "wagmi": "^2.15.6", + "web-haptics": "^0.0.6" }, "devDependencies": { "@dynamic-labs/types": "4.10.4", diff --git a/packages/ui/src/components/common/CustomAddressModal.tsx b/packages/ui/src/components/common/CustomAddressModal.tsx index 23f77e48..97a5162e 100644 --- a/packages/ui/src/components/common/CustomAddressModal.tsx +++ b/packages/ui/src/components/common/CustomAddressModal.tsx @@ -21,7 +21,10 @@ import type { AdaptedWallet, RelayChain } from '@relayprotocol/relay-sdk' import type { LinkedWallet } from '../../types/index.js' import { truncateAddress } from '../../utils/truncate.js' import { isValidAddress } from '../../utils/address.js' -import { ProviderOptionsContext } from '../../providers/RelayKitProvider.js' +import { + ProviderOptionsContext, + useHapticEvent +} from '../../providers/RelayKitProvider.js' import { addCustomAddress, getCustomAddresses @@ -56,6 +59,7 @@ export const CustomAddressModal: FC = ({ onConfirmed, onClear }) => { + const haptic = useHapticEvent() const connectedAddress = useWalletAddress(wallet, linkedWallets) const [address, setAddress] = useState('') const [input, setInput] = useState('') @@ -316,6 +320,7 @@ export const CustomAddressModal: FC = ({ radius="squared" className="relay:flex relay:items-center relay:gap-[6px] relay:cursor-pointer relay:px-2" onClick={() => { + haptic('light') onConfirmed(address) onOpenChange(false) onAnalyticEvent?.(EventNames.ADDRESS_MODAL_CONFIRMED, { @@ -354,6 +359,7 @@ export const CustomAddressModal: FC = ({ setRecentCustomAddresses(getCustomAddresses()) } + haptic('light') onConfirmed(address) onAnalyticEvent?.(EventNames.ADDRESS_MODAL_CONFIRMED, { address: address, diff --git a/packages/ui/src/components/common/SlippageToleranceConfig.tsx b/packages/ui/src/components/common/SlippageToleranceConfig.tsx index ec2bb609..eef1e387 100644 --- a/packages/ui/src/components/common/SlippageToleranceConfig.tsx +++ b/packages/ui/src/components/common/SlippageToleranceConfig.tsx @@ -29,6 +29,7 @@ const tokenToColor = (token: string | undefined): string | undefined => { return `var(--relay-colors-${token})` } import { EventNames } from '../../constants/events.js' +import { useHapticEvent } from '../../providers/RelayKitProvider.js' import { useDebounceValue, useMediaQuery } from 'usehooks-ts' import useFallbackState from '../../hooks/useFallbackState.js' import { Modal } from './Modal.js' @@ -73,11 +74,13 @@ const SlippageTabs: FC = ({ slippageRatingColor, inputRef }) => { + const haptic = useHapticEvent() const isMobile = useMediaQuery('(max-width: 520px)') return ( { + haptic('selection') setMode(value as SlippageToleranceMode) if (value === 'Auto') { setDisplayValue(undefined) diff --git a/packages/ui/src/components/common/TokenSelector/ChainFilter.tsx b/packages/ui/src/components/common/TokenSelector/ChainFilter.tsx index 3cc4653b..5dd32be4 100644 --- a/packages/ui/src/components/common/TokenSelector/ChainFilter.tsx +++ b/packages/ui/src/components/common/TokenSelector/ChainFilter.tsx @@ -24,6 +24,7 @@ import { } from '../../../utils/localStorage.js' import Tooltip from '../../../components/primitives/Tooltip.js' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import { ChainSearchInput } from './ChainFilterRow.js' import { cn } from '../../../utils/cn.js' @@ -279,6 +280,7 @@ const ChainFilterRow: FC = ({ showStar = true, onAnalyticEvent }) => { + const haptic = useHapticEvent() const [dropdownOpen, setDropdownOpen] = useState(false) const [longPressTimer, setLongPressTimer] = useState(null) const dropdownRef = useRef(null) @@ -325,6 +327,7 @@ const ChainFilterRow: FC = ({ if (chain.id) { const previouslyStarred = isStarred toggleStarredChain(chain.id) + haptic('light') const eventName = previouslyStarred ? EventNames.CHAIN_UNSTARRED : EventNames.CHAIN_STARRED @@ -341,10 +344,7 @@ const ChainFilterRow: FC = ({ const handleTouchStart = (_e: React.TouchEvent) => { if (!chain.id) return const timer = setTimeout(() => { - // Provide haptic feedback on long press - if ('vibrate' in navigator) { - navigator.vibrate(50) // Short 50ms vibration - } + haptic('heavy') setDropdownOpen(true) }, 500) // 500ms long press setLongPressTimer(timer) diff --git a/packages/ui/src/components/common/TokenSelector/ChainFilterRow.tsx b/packages/ui/src/components/common/TokenSelector/ChainFilterRow.tsx index ea726d1f..a8e81987 100644 --- a/packages/ui/src/components/common/TokenSelector/ChainFilterRow.tsx +++ b/packages/ui/src/components/common/TokenSelector/ChainFilterRow.tsx @@ -20,6 +20,7 @@ import { } from '../../../utils/localStorage.js' import { EventNames } from '../../../constants/events.js' import { cn } from '../../../utils/cn.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' export type ChainFilterRowProps = { chain: ChainFilterValue @@ -38,6 +39,7 @@ export const ChainFilterRow: FC = ({ onAnalyticEvent, children }) => { + const haptic = useHapticEvent() const [dropdownOpen, setDropdownOpen] = useState(false) const dropdownRef = useRef(null) const isStarred = chain.id ? isChainStarred(chain.id) : false @@ -75,6 +77,7 @@ export const ChainFilterRow: FC = ({ if (chain.id) { const previouslyStarred = isStarred toggleStarredChain(chain.id) + haptic('light') const eventName = previouslyStarred ? EventNames.CHAIN_UNSTARRED : EventNames.CHAIN_STARRED diff --git a/packages/ui/src/components/common/TokenSelector/ChainFilterSidebar.tsx b/packages/ui/src/components/common/TokenSelector/ChainFilterSidebar.tsx index 65313c22..159e6dc6 100644 --- a/packages/ui/src/components/common/TokenSelector/ChainFilterSidebar.tsx +++ b/packages/ui/src/components/common/TokenSelector/ChainFilterSidebar.tsx @@ -21,6 +21,7 @@ import { faStar } from '@fortawesome/free-solid-svg-icons/faStar' import Fuse from 'fuse.js' import type { ChainFilterValue } from './ChainFilter.js' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import type { RelayChain } from '@relayprotocol/relay-sdk' import AllChainsLogo from '../../../img/AllChainsLogo.js' import { TagPill } from './TagPill.js' @@ -69,6 +70,7 @@ export const ChainFilterSidebar: FC = ({ onChainStarToggle, starredChainIds }) => { + const haptic = useHapticEvent() const [chainSearchInput, setChainSearchInput] = useState('') const chainFuse = new Fuse(options, fuseSearchOptions) const activeChainRef = useRef(null) @@ -129,6 +131,7 @@ export const ChainFilterSidebar: FC = ({ (chain) => chain.id?.toString() === selectedValue ) if (chain) { + haptic('selection') onSelect(chain) const fromStarredList = !isSameChainSelection && @@ -340,6 +343,7 @@ const ChainFilterRow: FC = ({ showStar = true, onAnalyticEvent }) => { + const haptic = useHapticEvent() const [dropdownOpen, setDropdownOpen] = useState(false) const dropdownRef = useRef(null) const isStarred = chain.id ? isChainStarred(chain.id) : false @@ -382,6 +386,7 @@ const ChainFilterRow: FC = ({ if (chain.id) { const previouslyStarred = isStarred toggleStarredChain(chain.id) + haptic('light') const eventName = previouslyStarred ? EventNames.CHAIN_UNSTARRED : EventNames.CHAIN_STARRED diff --git a/packages/ui/src/components/common/TokenSelector/MobileChainSelector.tsx b/packages/ui/src/components/common/TokenSelector/MobileChainSelector.tsx index 1a2a13ab..4a1196ba 100644 --- a/packages/ui/src/components/common/TokenSelector/MobileChainSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/MobileChainSelector.tsx @@ -25,6 +25,7 @@ import { faXmark } from '@fortawesome/free-solid-svg-icons/faXmark' import Fuse from 'fuse.js' import type { ChainFilterValue } from './ChainFilter.js' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import type { RelayChain } from '@relayprotocol/relay-sdk' import AllChainsLogo from '../../../img/AllChainsLogo.js' import { TagPill } from './TagPill.js' @@ -70,6 +71,7 @@ export const MobileChainSelector: FC = ({ onChainStarToggle, starredChainIds }) => { + const haptic = useHapticEvent() const [chainSearchInput, setChainSearchInput] = useState('') const chainFuse = new Fuse(options, fuseSearchOptions) @@ -164,6 +166,7 @@ export const MobileChainSelector: FC = ({ (chain) => chain.id?.toString() === selectedValue ) if (chain) { + haptic('selection') const fromStarredList = !isSameChainSelection && chain.id !== undefined && @@ -305,6 +308,7 @@ const MobileChainRow: FC = ({ showStar = true, onAnalyticEvent }) => { + const haptic = useHapticEvent() const [dropdownOpen, setDropdownOpen] = useState(false) const [longPressTimer, setLongPressTimer] = useState(null) const dropdownRef = useRef(null) @@ -351,6 +355,7 @@ const MobileChainRow: FC = ({ if (chain.id) { const previouslyStarred = isStarred toggleStarredChain(chain.id) + haptic('light') const eventName = previouslyStarred ? EventNames.CHAIN_UNSTARRED : EventNames.CHAIN_STARRED @@ -368,10 +373,7 @@ const MobileChainRow: FC = ({ (e: React.TouchEvent) => { if (!chain.id) return const timer = setTimeout(() => { - // Provide haptic feedback on long press - if ('vibrate' in navigator) { - navigator.vibrate(50) // Short 50ms vibration - } + haptic('heavy') setDropdownOpen(true) }, 500) // 500ms long press setLongPressTimer(timer) diff --git a/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx b/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx index 92fdda9f..359acc86 100644 --- a/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx +++ b/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx @@ -21,6 +21,7 @@ import { useMultiWalletBalances } from '../../../hooks/useMultiWalletBalances.js import { useMediaQuery } from 'usehooks-ts' import { useTokenList } from '@relayprotocol/relay-kit-hooks' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import { UnverifiedTokenModal } from '../UnverifiedTokenModal.js' import { useEnhancedTokensList } from '../../../hooks/useEnhancedTokensList.js' import { TokenList } from './TokenList.js' @@ -81,6 +82,7 @@ const PaymentMethod: FC = ({ onAnalyticEvent, onPaymentMethodOpenChange }) => { + const haptic = useHapticEvent() const relayClient = useRelayClient() const { chains: allRelayChains } = useInternalRelayChains() @@ -416,6 +418,7 @@ const PaymentMethod: FC = ({ const handleTokenSelection = useCallback( (selectedToken: Token) => { + haptic('light') const isVerified = selectedToken.verified const direction = context === 'from' ? 'input' : 'output' let position = undefined @@ -466,6 +469,7 @@ const PaymentMethod: FC = ({ onOpenChange(false) }, [ + haptic, setToken, onOpenChange, resetState, diff --git a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx index 555c81e4..95cfad21 100644 --- a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx @@ -24,6 +24,7 @@ import { useTrendingCurrencies } from '@relayprotocol/relay-kit-hooks' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import { UnverifiedTokenModal } from '../UnverifiedTokenModal.js' import { useEnhancedTokensList } from '../../../hooks/useEnhancedTokensList.js' import { TokenList } from './TokenList.js' @@ -90,6 +91,7 @@ const TokenSelector: FC = ({ setToken, onAnalyticEvent }) => { + const haptic = useHapticEvent() const relayClient = useRelayClient() const { chains: allRelayChains } = useInternalRelayChains() @@ -489,6 +491,7 @@ const TokenSelector: FC = ({ const handleTokenSelection = useCallback( (selectedToken: Token) => { + haptic('light') const isVerified = selectedToken.verified const direction = context === 'from' ? 'input' : 'output' let position = undefined @@ -539,6 +542,7 @@ const TokenSelector: FC = ({ onOpenChange(false) }, [ + haptic, setToken, onOpenChange, resetState, diff --git a/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx b/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx index 24939323..8f7fedd6 100644 --- a/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx +++ b/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx @@ -10,6 +10,7 @@ import { Modal } from '../Modal.js' import { Flex, Text } from '../../primitives/index.js' import { ErrorStep } from './steps/ErrorStep.js' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import { type Token } from '../../../types/index.js' import { SwapSuccessStep } from './steps/SwapSuccessStep.js' import { formatBN } from '../../../utils/numbers.js' @@ -51,6 +52,7 @@ export const DepositAddressModal: FC = ( onAnalyticEvent, onSuccess } = depositAddressModalProps + const haptic = useHapticEvent() useEffect(() => { onOpenChange(open) @@ -90,6 +92,7 @@ export const DepositAddressModal: FC = ( const quoteId = quote ? extractQuoteId(quote?.steps as Execute['steps']) : undefined + haptic('success') onAnalyticEvent?.(EventNames.SWAP_SUCCESS, { ...extraData, chain_id_in: fromToken?.chainId, diff --git a/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx b/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx index ba86998c..a8941b88 100644 --- a/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx +++ b/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx @@ -27,6 +27,7 @@ import { } from '@relayprotocol/relay-kit-hooks' import { useRelayClient } from '../../../hooks/index.js' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import { ProviderOptionsContext } from '../../../providers/RelayKitProvider.js' import { useAccount } from 'wagmi' import { @@ -105,6 +106,7 @@ export const DepositAddressModalRenderer: FC = ({ onAnalyticEvent, onSwapError }) => { + const haptic = useHapticEvent() const [quoteData, setQuoteData] = useState(null) const [fetchingQuote, setFetchingQuote] = useState(false) const [quoteError, setQuoteError] = useState(null) @@ -261,6 +263,7 @@ export const DepositAddressModalRenderer: FC = ({ onSwapError?.(swapError.message, quote as Execute) } setProgressStep(TransactionProgressStep.Error) + haptic('error') onAnalyticEvent?.(EventNames.DEPOSIT_ADDRESS_SWAP_ERROR, { error_message: errorToJSON(executionStatus?.details ?? quoteError), wallet_connector: connector?.name, diff --git a/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx b/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx index e1dc3787..18b920b5 100644 --- a/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx +++ b/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx @@ -14,6 +14,7 @@ import { Modal } from '../Modal.js' import { Flex, Text } from '../../primitives/index.js' import { ErrorStep } from './steps/ErrorStep.js' import { EventNames } from '../../../constants/events.js' +import { useHapticEvent } from '../../../providers/RelayKitProvider.js' import { SwapConfirmationStep } from './steps/SwapConfirmationStep.js' import { type Token } from '../../../types/index.js' import { SwapSuccessStep } from './steps/SwapSuccessStep.js' @@ -64,6 +65,7 @@ export const TransactionModal: FC = ( onSuccess, onSwapValidating } = transactionModalProps + const haptic = useHapticEvent() useEffect(() => { onOpenChange(open) @@ -115,6 +117,7 @@ export const TransactionModal: FC = ( extraData.relayer_fee = parseFloat(fees.relayer.amountFormatted) } const quoteId = steps ? extractQuoteId(steps) : undefined + haptic('success') onAnalyticEvent?.(EventNames.SWAP_SUCCESS, { ...extraData, chain_id_in: fromToken?.chainId, @@ -203,6 +206,7 @@ const InnerTransactionModal: FC = ({ linkedWallets, currentCheckStatus }) => { + const haptic = useHapticEvent() useEffect(() => { if (!open) { if (currentStep) { diff --git a/packages/ui/src/components/common/UnverifiedTokenModal.tsx b/packages/ui/src/components/common/UnverifiedTokenModal.tsx index b2cf61c1..f9e18e6a 100644 --- a/packages/ui/src/components/common/UnverifiedTokenModal.tsx +++ b/packages/ui/src/components/common/UnverifiedTokenModal.tsx @@ -14,6 +14,7 @@ import { } from '../../utils/localStorage.js' import { EventNames } from '../../constants/events.js' import { cn } from '../../utils/cn.js' +import { useHapticEvent } from '../../providers/RelayKitProvider.js' type UnverifiedTokenModalProps = { open: boolean @@ -32,6 +33,7 @@ export const UnverifiedTokenModal: FC = ({ onDecline, onAnalyticEvent }) => { + const haptic = useHapticEvent() const client = useRelayClient() const chain = client?.chains?.find( (chain) => chain.id === data?.token?.chainId @@ -134,6 +136,7 @@ export const UnverifiedTokenModal: FC = ({ ] }) } + haptic('light') onAnalyticEvent?.(EventNames.UNVERIFIED_TOKEN_ACCEPTED, { token: data?.token, context: data?.context diff --git a/packages/ui/src/components/widgets/OnrampWidget/modals/OnrampModal.tsx b/packages/ui/src/components/widgets/OnrampWidget/modals/OnrampModal.tsx index 5b902f89..7f8dbe0c 100644 --- a/packages/ui/src/components/widgets/OnrampWidget/modals/OnrampModal.tsx +++ b/packages/ui/src/components/widgets/OnrampWidget/modals/OnrampModal.tsx @@ -9,6 +9,7 @@ import { Modal } from '../../../common/Modal.js' import type { FiatCurrency, Token } from '../../../../types/index.js' import useRelayClient from '../../../../hooks/useRelayClient.js' import { EventNames } from '../../../../constants/events.js' +import { useHapticEvent } from '../../../../providers/RelayKitProvider.js' import { useDepositAddressStatus, useQuote, @@ -96,6 +97,7 @@ export const OnrampModal: FC = ({ onError, onOpenChange }) => { + const haptic = useHapticEvent() const [swapError, setSwapError] = useState(null) const [step, setStep] = useState(OnrampStep.Confirming) const [processingStep, setProcessingStep] = useState< @@ -298,6 +300,7 @@ export const OnrampModal: FC = ({ } if (executionStatus?.status === 'success') { setStep(OnrampStep.Success) + haptic('success') onAnalyticEvent?.(EventNames.ONRAMP_SUCCESS, { chain_id_in: fromToken?.chainId, currency_in: fromToken?.symbol, @@ -387,6 +390,7 @@ export const OnrampModal: FC = ({ onError?.(error.message, quote as Execute, moonPayRequestId) } setStep(OnrampStep.Error) + haptic('error') onAnalyticEvent?.(EventNames.ONRAMP_ERROR, { error_message: errorMsg, wallet_connector: connector?.name, diff --git a/packages/ui/src/components/widgets/OnrampWidget/modals/steps/OnrampConfirmingStep.tsx b/packages/ui/src/components/widgets/OnrampWidget/modals/steps/OnrampConfirmingStep.tsx index 271705a2..67c9ab71 100644 --- a/packages/ui/src/components/widgets/OnrampWidget/modals/steps/OnrampConfirmingStep.tsx +++ b/packages/ui/src/components/widgets/OnrampWidget/modals/steps/OnrampConfirmingStep.tsx @@ -11,6 +11,7 @@ import { OnrampStep } from '../OnrampModal.js' import type { RelayChain } from '@relayprotocol/relay-sdk' import { LoadingSpinner } from '../../../../common/LoadingSpinner.js' import { EventNames } from '../../../../../constants/events.js' +import { useHapticEvent } from '../../../../../providers/RelayKitProvider.js' type OnrampConfirmingStepProps = { toToken: Token @@ -43,6 +44,7 @@ export const OnrampConfirmingStep: FC = ({ onAnalyticEvent, setStep }) => { + const haptic = useHapticEvent() return ( = ({ disabled={!depositAddress || isFetchingQuote} className="relay:justify-center" onClick={(e) => { + haptic('medium') onAnalyticEvent?.(EventNames.ONRAMP_CTA_CLICKED, { recipient, depositAddress, diff --git a/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx b/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx index 48c3bfe1..31849ed1 100644 --- a/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx +++ b/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx @@ -19,7 +19,10 @@ import { useAccount } from 'wagmi' import { OnrampModal } from '../modals/OnrampModal.js' import { formatBN } from '../../../../utils/numbers.js' import { findSupportedWallet } from '../../../../utils/address.js' -import { ProviderOptionsContext } from '../../../../providers/RelayKitProvider.js' +import { + ProviderOptionsContext, + useHapticEvent +} from '../../../../providers/RelayKitProvider.js' type BaseOnrampWidgetProps = { defaultWalletAddress?: string @@ -92,6 +95,7 @@ const OnrampWidget: FC = ({ const [onrampModalOpen, setOnrampModalOpen] = useState(false) const { isConnected } = useAccount() const providerOptionsContext = useContext(ProviderOptionsContext) + const haptic = useHapticEvent() const connectorKeyOverrides = providerOptionsContext.vmConnectorKeyOverrides return ( @@ -533,6 +537,7 @@ const OnrampWidget: FC = ({ className="relay:w-full relay:justify-center" disabled={notEnoughFiat} onClick={() => { + haptic('medium') if (!recipient && toChainWalletVMSupported) { if (!linkedWallets || linkedWallets.length === 0) { onConnectWallet?.() diff --git a/packages/ui/src/components/widgets/SwapButton.tsx b/packages/ui/src/components/widgets/SwapButton.tsx index e55a4feb..c2bd50ed 100644 --- a/packages/ui/src/components/widgets/SwapButton.tsx +++ b/packages/ui/src/components/widgets/SwapButton.tsx @@ -4,6 +4,7 @@ import { useMounted } from '../../hooks/index.js' import type { ChildrenProps } from './SwapWidgetRenderer.js' import { EventNames } from '../../constants/events.js' import { cn } from '../../utils/cn.js' +import { useHapticEvent } from '../../providers/RelayKitProvider.js' type SwapButtonProps = { transactionModalOpen: boolean @@ -59,6 +60,7 @@ const SwapButton: FC = ({ isFetchingQuote }) => { const isMounted = useMounted() + const haptic = useHapticEvent() if (isMounted && (address || !fromChainWalletVMSupported)) { const invalidAmount = @@ -129,6 +131,7 @@ const SwapButton: FC = ({ throw 'Missing onWalletConnect function' } + haptic('medium') onConnectWallet() onAnalyticEvent?.(EventNames.CONNECT_WALLET_CLICKED, { context diff --git a/packages/ui/src/components/widgets/SwapWidget/index.tsx b/packages/ui/src/components/widgets/SwapWidget/index.tsx index 590b9386..a5007bcc 100644 --- a/packages/ui/src/components/widgets/SwapWidget/index.tsx +++ b/packages/ui/src/components/widgets/SwapWidget/index.tsx @@ -42,7 +42,10 @@ import { isChainVmTypeSupported } from '../../../utils/address.js' import { isDeadAddress, tronDeadAddress } from '@relayprotocol/relay-sdk' -import { ProviderOptionsContext } from '../../../providers/RelayKitProvider.js' +import { + ProviderOptionsContext, + useHapticEvent +} from '../../../providers/RelayKitProvider.js' import { findBridgableToken } from '../../../utils/tokens.js' import { isChainLocked } from '../../../utils/tokenSelector.js' import TokenSelector from '../../common/TokenSelector/TokenSelector.js' @@ -144,6 +147,7 @@ const SwapWidget: FC = ({ [_onAnalyticEvent] ) const relayClient = useRelayClient() + const haptic = useHapticEvent() const providerOptionsContext = useContext(ProviderOptionsContext) const connectorKeyOverrides = providerOptionsContext.vmConnectorKeyOverrides const [transactionModalOpen, setTransactionModalOpen] = useState(false) @@ -293,6 +297,7 @@ const SwapWidget: FC = ({ setTradeType('EXACT_INPUT') debouncedAmountOutputControls.cancel() debouncedAmountInputControls.flush() + haptic('light') onAnalyticEvent?.(EventNames.MAX_AMOUNT_CLICKED, { percent: percent, bufferAmount: bufferAmount ? bufferAmount.toString() : '0', @@ -1037,6 +1042,7 @@ const SwapWidget: FC = ({ color="white" className="relay:mt-[4px] relay:text-[color:var(--relay-colors-gray9)] relay:self-center relay:justify-center relay:w-full relay:h-full relay:z-10 relay:border-[length:var(--relay-borders-widget-swap-currency-button-border-width)] relay:border-solid relay:!border-[color:var(--relay-colors-widget-swap-currency-button-border-color)] relay:rounded-swap-btn relay:hover:text-[color:var(--relay-colors-gray11)] relay:hover:bg-[var(--relay-colors-gray-2)]" onClick={() => { + haptic('light') if (fromToken || toToken) { if (isUsdInputMode) { // In USD mode, switch the tokens and values @@ -1279,6 +1285,7 @@ const SwapWidget: FC = ({ gasTopUpEnabled={gasTopUpEnabled} onGasTopUpEnabled={(enabled) => { setGasTopUpEnabled(enabled) + haptic('light') onAnalyticEvent?.(EventNames.GAS_TOP_UP_TOGGLE, { enabled, amount: gasTopUpAmount, @@ -1491,6 +1498,7 @@ const SwapWidget: FC = ({ linkedWallet?.connector, quoteParameters ) + haptic('medium') onAnalyticEvent?.( EventNames.SWAP_CTA_CLICKED, swapEventData @@ -1524,6 +1532,7 @@ const SwapWidget: FC = ({ }} onAcceptToken={(token, context) => { if (token) { + haptic('light') if (context === 'to') { onAnalyticEvent?.(EventNames.SWAP_TOKEN_SELECT, { direction: 'output', diff --git a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx index 5c056acb..f9623d65 100644 --- a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx @@ -32,7 +32,10 @@ import { } from '../../utils/quote.js' import { useQuote, useTokenPrice } from '@relayprotocol/relay-kit-hooks' import { EventNames } from '../../constants/events.js' -import { ProviderOptionsContext } from '../../providers/RelayKitProvider.js' +import { + ProviderOptionsContext, + useHapticEvent +} from '../../providers/RelayKitProvider.js' import type { DebouncedState } from 'usehooks-ts' import type { AdaptedWallet } from '@relayprotocol/relay-sdk' import type { LinkedWallet } from '../../types/index.js' @@ -185,6 +188,7 @@ const SwapWidgetRenderer: FC = ({ onAnalyticEvent, onSwapError }) => { + const haptic = useHapticEvent() const [fromToken, setFromToken] = useFallbackState( _setFromToken ? _fromToken : undefined, _setFromToken @@ -872,6 +876,7 @@ const SwapWidgetRenderer: FC = ({ onAnalyticEvent?.(EventNames.SWAP_ERROR, swapEventData) } + haptic('error') setSwapError(errorMessage) onSwapError?.(errorMessage, { ...quote, steps: currentSteps } as Execute) } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 755ccc5a..4490da75 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -4,6 +4,7 @@ export { defaultTheme } from './themes/index.js' //Providers export { RelayKitProvider } from './providers/RelayKitProvider.js' +export type { HapticEventType } from './providers/RelayKitProvider.js' export { RelayClientProvider } from './providers/RelayClientProvider.js' //hooks diff --git a/packages/ui/src/providers/RelayKitProvider.tsx b/packages/ui/src/providers/RelayKitProvider.tsx index 13e8e28e..3dc1b4ed 100644 --- a/packages/ui/src/providers/RelayKitProvider.tsx +++ b/packages/ui/src/providers/RelayKitProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useMemo } from 'react' +import { createContext, useCallback, useContext, useMemo } from 'react' import type { FC, ReactElement, ReactNode } from 'react' import { RelayClientProvider } from './RelayClientProvider.js' import type { RelayClientOptions, paths } from '@relayprotocol/relay-sdk' @@ -8,6 +8,26 @@ import { generateCssVars } from '../utils/theme.js' export type AppFees = paths['/quote/v2']['post']['requestBody']['content']['application/json']['appFees'] +/** + * Haptic feedback intensity/type for UI interactions. + * + * - `light` — Subtle tap for minor interactions: token selection, chain starring, toggle switches, max button clicks + * - `medium` — Noticeable tap for deliberate actions: swap CTA button press + * - `heavy` — Strong tap for emphatic interactions: long-press actions + * - `selection` — Ultra-light discrete tick for picker-like interactions: tab switches, chain filter selection + * - `success` — Distinct success pattern: swap completed, onramp completed + * - `error` — Distinct error pattern: swap failed, approval failed, onramp failed + * - `warning` — Alert pattern: unverified token modal shown + */ +export type HapticEventType = + | 'light' + | 'medium' + | 'heavy' + | 'selection' + | 'success' + | 'error' + | 'warning' + type RelayKitProviderOptions = { /** * The name of the application @@ -57,6 +77,35 @@ type RelayKitProviderOptions = { * Currently only relevant for the quote api in the SwapWidget */ secureBaseUrl?: string + /** + * Optional callback for haptic feedback on UI interactions. + * Relay Kit does not bundle any haptics library — integrators provide their own implementation. + * + * @example + * ```tsx + * import { useWebHaptics } from 'web-haptics/react' + * + * function App() { + * const { trigger } = useWebHaptics() + * + * return ( + * { + * // Map Relay Kit types to your haptics library + * const map = { light: 'nudge', medium: 'buzz', heavy: 'buzz', success: 'success', error: 'error', warning: 'error' } + * trigger(map[type] ?? type) + * } + * }} + * > + * + * + * ) + * } + * ``` + */ + onHapticEvent?: (type: HapticEventType) => void } export interface RelayKitProviderProps { @@ -182,7 +231,8 @@ export const RelayKitProvider: FC = function ({ privateChainIds: options.privateChainIds, themeScheme: options.themeScheme, loader: options.loader, - secureBaseUrl: options.secureBaseUrl + secureBaseUrl: options.secureBaseUrl, + onHapticEvent: options.onHapticEvent }), [options] ) @@ -208,3 +258,21 @@ export const RelayKitProvider: FC = function ({ ) } + +/** + * Hook that returns a stable haptic event callback from the RelayKitProvider context. + * Wraps the integrator's callback in a try-catch to prevent haptic errors from breaking the UI. + */ +export function useHapticEvent() { + const { onHapticEvent } = useContext(ProviderOptionsContext) + return useCallback( + (type: HapticEventType) => { + try { + onHapticEvent?.(type) + } catch (e) { + console.error('Error in onHapticEvent', type, e) + } + }, + [onHapticEvent] + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4abc954..d864c77a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: wagmi: specifier: ^2.15.6 version: 2.15.6(@tanstack/query-core@5.66.4)(@tanstack/react-query@5.66.9(react@19.1.1))(@types/react@19.1.11)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.29.4(bufferutil@4.0.9)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.22.4))(zod@3.22.4) + web-haptics: + specifier: ^0.0.6 + version: 0.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) devDependencies: '@dynamic-labs/types': specifier: 4.10.4 @@ -6556,6 +6559,23 @@ packages: typescript: optional: true + web-haptics@0.0.6: + resolution: {integrity: sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + svelte: '>=4' + vue: '>=3' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -15852,6 +15872,11 @@ snapshots: - utf-8-validate - zod + web-haptics@0.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + web-streams-polyfill@3.3.3: {} webextension-polyfill@0.10.0: {} From 18a72dc245afa83b95ee96fff27a501aeb335d37 Mon Sep 17 00:00:00 2001 From: Ted Palmer Date: Fri, 6 Mar 2026 13:29:09 -0500 Subject: [PATCH 2/7] Make buttons more interactove --- packages/ui/src/components/common/PercentageButtons.tsx | 3 ++- .../ui/src/components/common/SlippageToleranceConfig.tsx | 6 +++--- .../common/TransactionModal/steps/SwapSuccessStep.tsx | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/common/PercentageButtons.tsx b/packages/ui/src/components/common/PercentageButtons.tsx index 76b3962e..6c589cf5 100644 --- a/packages/ui/src/components/common/PercentageButtons.tsx +++ b/packages/ui/src/components/common/PercentageButtons.tsx @@ -59,7 +59,8 @@ export const PercentageButtons: FC = ({ isMobile ? 'relay:rounded-[6px]' : 'relay:rounded-[12px]', isMobile ? 'relay:flex-1' : '', 'relay:justify-center', - 'relay:hover:bg-[var(--relay-colors-widget-selector-hover-background)]' + 'relay:hover:bg-[var(--relay-colors-widget-selector-hover-background)]', + 'relay:active:bg-[var(--relay-colors-gray5)]' ) const buttonFontSize = isMobile ? '14px' : '12px' diff --git a/packages/ui/src/components/common/SlippageToleranceConfig.tsx b/packages/ui/src/components/common/SlippageToleranceConfig.tsx index eef1e387..d2b93ac0 100644 --- a/packages/ui/src/components/common/SlippageToleranceConfig.tsx +++ b/packages/ui/src/components/common/SlippageToleranceConfig.tsx @@ -118,7 +118,7 @@ const SlippageTabs: FC = ({