├── .nvmrc ├── .watchmanconfig ├── src ├── __tests__ │ └── index.test.tsx ├── index.tsx ├── pressables │ ├── index.ts │ ├── custom │ │ ├── index.ts │ │ ├── withoutFeedback.ts │ │ ├── opacity.ts │ │ └── scale.ts │ ├── hoc.tsx │ └── base.tsx └── provider │ ├── hooks.ts │ ├── context.ts │ ├── constants.ts │ └── index.tsx ├── example ├── .eslintignore ├── assets │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ └── adaptive-icon.png ├── tsconfig.json ├── babel.config.js ├── metro.config.js ├── app │ ├── _layout.tsx │ ├── screen.tsx │ ├── index.tsx │ ├── options-example.tsx │ └── metadata-example.tsx ├── app.json ├── eslint.config.js └── package.json ├── .gitattributes ├── tsconfig.build.json ├── .github ├── FUNDING.yml ├── actions │ └── setup │ │ └── action.yml └── workflows │ └── ci.yml ├── .eslintignore ├── eslint-plugin-pressto ├── index.js ├── package.json ├── LICENSE ├── README.md └── rules │ └── require-worklet-directive.js ├── lefthook.yml ├── .editorconfig ├── jest.config.js ├── babel.config.js ├── tsconfig.json ├── LICENSE ├── eslint.config.js ├── .gitignore ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | it.todo('write a test'); 2 | -------------------------------------------------------------------------------- /example/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | ios/ 5 | android/ 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './pressables'; 2 | export * from './provider'; 3 | -------------------------------------------------------------------------------- /src/pressables/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom'; 2 | export * from './hoc'; 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "exclude": ["example", "lib"] 4 | } 5 | -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enzomanuelmangano/pressto/HEAD/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enzomanuelmangano/pressto/HEAD/example/assets/favicon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enzomanuelmangano/pressto/HEAD/example/assets/splash.png -------------------------------------------------------------------------------- /example/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enzomanuelmangano/pressto/HEAD/example/assets/adaptive-icon.png -------------------------------------------------------------------------------- /src/pressables/custom/index.ts: -------------------------------------------------------------------------------- 1 | export * from './opacity'; 2 | export * from './scale'; 3 | export * from './withoutFeedback'; 4 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | // Avoid expo-cli auto-generating a tsconfig 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: enzomanuelmangano 4 | custom: ["https://reactiive.io/demos", "https://reanimate.dev"] 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | example/node_modules/ 5 | example/android/ 6 | example/ios/ 7 | example/.expo/ 8 | example/dist/ 9 | *.config.js 10 | -------------------------------------------------------------------------------- /src/pressables/custom/withoutFeedback.ts: -------------------------------------------------------------------------------- 1 | import { createAnimatedPressable } from '../hoc'; 2 | 3 | export const PressableWithoutFeedback = createAnimatedPressable(() => { 4 | 'worklet'; 5 | return {}; 6 | }); 7 | -------------------------------------------------------------------------------- /eslint-plugin-pressto/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ESLint plugin for pressto 3 | * Enforces best practices when using pressto's createAnimatedPressable 4 | */ 5 | module.exports = { 6 | rules: { 7 | 'require-worklet-directive': require('./rules/require-worklet-directive'), 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | lint: 5 | glob: "*.{js,ts,jsx,tsx}" 6 | run: npx eslint {staged_files} 7 | types: 8 | glob: "*.{js,ts, jsx, tsx}" 9 | run: npx tsc 10 | commit-msg: 11 | parallel: true 12 | commands: 13 | commitlint: 14 | run: npx commitlint --edit 15 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Setup Bun and install dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup Bun 8 | uses: oven-sh/setup-bun@v2 9 | with: 10 | bun-version: latest 11 | 12 | - name: Install dependencies 13 | run: bun install 14 | shell: bash 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /src/pressables/custom/opacity.ts: -------------------------------------------------------------------------------- 1 | import { interpolate } from 'react-native-reanimated'; 2 | import { createAnimatedPressable } from '../hoc'; 3 | 4 | export const PressableOpacity = createAnimatedPressable((progress, { config }) => { 5 | 'worklet'; 6 | return { 7 | opacity: interpolate(progress, [0, 1], [1, config.activeOpacity]), 8 | }; 9 | }); 10 | -------------------------------------------------------------------------------- /src/provider/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { PressablesContext, PressablesGroupContext } from './context'; 3 | 4 | export const usePressablesConfig = () => { 5 | return useContext(PressablesContext); 6 | }; 7 | 8 | export const useLastTouchedPressable = () => { 9 | return useContext(PressablesGroupContext).lastTouchedPressable; 10 | }; 11 | -------------------------------------------------------------------------------- /src/pressables/custom/scale.ts: -------------------------------------------------------------------------------- 1 | import { interpolate } from 'react-native-reanimated'; 2 | import { createAnimatedPressable } from '../hoc'; 3 | 4 | export const PressableScale = createAnimatedPressable((progress, { config }) => { 5 | 'worklet'; 6 | return { 7 | transform: [ 8 | { 9 | scale: interpolate(progress, [0, 1], [config.baseScale, config.minScale]), 10 | }, 11 | ], 12 | }; 13 | }); 14 | -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getConfig } = require('react-native-builder-bob/babel-config'); 3 | const pkg = require('../package.json'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | 7 | module.exports = function (api) { 8 | api.cache(true); 9 | 10 | return getConfig( 11 | { 12 | presets: ['babel-preset-expo'], 13 | plugins: ['react-native-worklets/plugin'], 14 | }, 15 | { root, pkg } 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | modulePathIgnorePatterns: [ 4 | '/example/node_modules', 5 | '/lib/', 6 | ], 7 | transformIgnorePatterns: [ 8 | 'node_modules/(?!(react-native-reanimated|react-native-gesture-handler|react-native-worklets)/)', 9 | ], 10 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 11 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 12 | }; 13 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getDefaultConfig } = require('expo/metro-config'); 3 | 4 | const projectRoot = __dirname; 5 | const monorepoRoot = path.resolve(projectRoot, '..'); 6 | 7 | const config = getDefaultConfig(projectRoot); 8 | 9 | // Watch all files in the monorepo 10 | config.watchFolders = [monorepoRoot]; 11 | 12 | // Let Metro know where to resolve packages from 13 | config.resolver.nodeModulesPaths = [ 14 | path.resolve(projectRoot, 'node_modules'), 15 | path.resolve(monorepoRoot, 'node_modules'), 16 | ]; 17 | 18 | // Force Metro to resolve peer dependencies from node_modules 19 | config.resolver.disableHierarchicalLookup = false; 20 | 21 | module.exports = config; 22 | -------------------------------------------------------------------------------- /example/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from 'expo-router'; 2 | import { PressablesConfig } from 'pressto'; 3 | import { StyleSheet } from 'react-native'; 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 5 | 6 | const globalHandlers = { 7 | onPress: () => { 8 | console.log('use haptics!'); 9 | }, 10 | }; 11 | 12 | export default function RootLayout() { 13 | return ( 14 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | const styles = StyleSheet.create({ 27 | container: { 28 | flex: 1, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['module:react-native-builder-bob/babel-preset', { modules: 'commonjs' }], 4 | ], 5 | env: { 6 | test: { 7 | presets: [ 8 | ['@babel/preset-env', { targets: { node: 'current' } }], 9 | '@babel/preset-react', 10 | [ 11 | '@babel/preset-typescript', 12 | { allowDeclareFields: true, isTSX: true, allExtensions: true }, 13 | ], 14 | ], 15 | }, 16 | }, 17 | overrides: [ 18 | { 19 | test: /node_modules/, 20 | presets: [ 21 | ['@babel/preset-env', { targets: { node: 'current' } }], 22 | '@babel/preset-react', 23 | [ 24 | '@babel/preset-typescript', 25 | { allowDeclareFields: true, isTSX: true, allExtensions: true }, 26 | ], 27 | ], 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "paths": { 5 | "pressto": ["./src/index"] 6 | }, 7 | "allowUnreachableCode": false, 8 | "allowUnusedLabels": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react-jsx", 12 | "lib": ["ESNext"], 13 | "module": "ESNext", 14 | "moduleResolution": "Node10", 15 | "noEmit": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitReturns": true, 18 | "noImplicitUseStrict": false, 19 | "noStrictGenericChecks": false, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "resolveJsonModule": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "target": "ESNext", 27 | "verbatimModuleSyntax": true, 28 | "types": ["react", "jest"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "example", 4 | "slug": "example", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "newArchEnabled": true, 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "scheme": "your-app-scheme", 16 | "ios": { 17 | "supportsTablet": true, 18 | "bundleIdentifier": "reactiive.pressables.example" 19 | }, 20 | "android": { 21 | "adaptiveIcon": { 22 | "foregroundImage": "./assets/adaptive-icon.png", 23 | "backgroundColor": "#ffffff" 24 | }, 25 | "package": "reactiive.pressables.example" 26 | }, 27 | "web": { 28 | "favicon": "./assets/favicon.png", 29 | "bundler": "metro" 30 | }, 31 | "plugins": ["expo-router"] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/eslint.config.js: -------------------------------------------------------------------------------- 1 | const reactPlugin = require('eslint-plugin-react'); 2 | const reactHooksPlugin = require('eslint-plugin-react-hooks'); 3 | const prettierConfig = require('eslint-config-prettier'); 4 | const tsParser = require('@typescript-eslint/parser'); 5 | const presstoPlugin = require('eslint-plugin-pressto'); 6 | 7 | module.exports = [ 8 | { 9 | files: ['**/*.{js,jsx,ts,tsx}'], 10 | languageOptions: { 11 | parser: tsParser, 12 | parserOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | }, 20 | plugins: { 21 | 'react': reactPlugin, 22 | 'react-hooks': reactHooksPlugin, 23 | 'pressto': presstoPlugin, 24 | }, 25 | rules: { 26 | 'react/react-in-jsx-scope': 'off', 27 | 'pressto/require-worklet-directive': 'error', 28 | ...prettierConfig.rules, 29 | }, 30 | }, 31 | { 32 | ignores: ['node_modules/', '.expo/', 'dist/', 'ios/', 'android/'], 33 | }, 34 | ]; 35 | -------------------------------------------------------------------------------- /eslint-plugin-pressto/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-pressto", 3 | "version": "0.1.0", 4 | "description": "ESLint plugin for pressto - enforces worklet directives in createAnimatedPressable functions", 5 | "main": "index.js", 6 | "keywords": [ 7 | "eslint", 8 | "eslint-plugin", 9 | "pressto", 10 | "react-native", 11 | "reanimated", 12 | "worklet", 13 | "linter" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/enzomanuelmangano/pressto.git", 18 | "directory": "eslint-plugin-pressto" 19 | }, 20 | "author": "Enzo Manuel Mangano (https://github.com/enzomanuelmangano)", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/enzomanuelmangano/pressto/issues" 24 | }, 25 | "homepage": "https://github.com/enzomanuelmangano/pressto#readme", 26 | "peerDependencies": { 27 | "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" 28 | }, 29 | "engines": { 30 | "node": ">=12.0.0" 31 | }, 32 | "files": [ 33 | "index.js", 34 | "rules/" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Enzo Manuel Mangano 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const reactNativePlugin = require('@react-native/eslint-plugin'); 2 | const reactPlugin = require('eslint-plugin-react'); 3 | const reactHooksPlugin = require('eslint-plugin-react-hooks'); 4 | const prettierConfig = require('eslint-config-prettier'); 5 | const tsParser = require('@typescript-eslint/parser'); 6 | const presstoPlugin = require('eslint-plugin-pressto'); 7 | 8 | module.exports = [ 9 | { 10 | files: ['**/*.{js,jsx,ts,tsx}'], 11 | languageOptions: { 12 | parser: tsParser, 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | }, 21 | plugins: { 22 | 'react': reactPlugin, 23 | 'react-hooks': reactHooksPlugin, 24 | '@react-native': reactNativePlugin, 25 | 'pressto': presstoPlugin, 26 | }, 27 | rules: { 28 | 'react/react-in-jsx-scope': 'off', 29 | 'pressto/require-worklet-directive': 'error', 30 | ...prettierConfig.rules, 31 | }, 32 | }, 33 | { 34 | ignores: ['node_modules/', 'lib/', 'coverage/', 'dist/'], 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /eslint-plugin-pressto/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Enzo Manuel Mangano 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | android.iml 42 | 43 | # Cocoapods 44 | # 45 | example/ios/Pods 46 | 47 | # Ruby 48 | example/vendor/ 49 | 50 | # node.js 51 | # 52 | node_modules/ 53 | npm-debug.log 54 | yarn-debug.log 55 | yarn-error.log 56 | 57 | # BUCK 58 | buck-out/ 59 | \.buckd/ 60 | android/app/libs 61 | android/keystores/debug.keystore 62 | 63 | # Yarn 64 | .yarn/* 65 | !.yarn/patches 66 | !.yarn/plugins 67 | !.yarn/releases 68 | !.yarn/sdks 69 | !.yarn/versions 70 | 71 | # Expo 72 | .expo/ 73 | 74 | # Turborepo 75 | .turbo/ 76 | 77 | # generated by bob 78 | lib/ 79 | 80 | # React Native Codegen 81 | ios/generated 82 | android/generated 83 | 84 | .cursor 85 | 86 | dist 87 | 88 | coverage -------------------------------------------------------------------------------- /src/pressables/hoc.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { ViewStyle } from 'react-native'; 4 | import type { AnimatedPressableOptions } from '../provider/context'; 5 | import type { 6 | AnimatedPressableStyleOptions, 7 | BasePressableProps, 8 | PressableChildrenCallbackParams, 9 | } from './base'; 10 | import { BasePressable } from './base'; 11 | 12 | export type { 13 | AnimatedPressableOptions, 14 | AnimatedPressableStyleOptions, 15 | PressableChildrenCallbackParams, 16 | }; 17 | 18 | export type CustomPressableProps = Omit; 19 | 20 | const withAnimatedTapStyle = ( 21 | WrappedComponent: React.ComponentType>, 22 | animatedStyle: ( 23 | progress: number, 24 | options: AnimatedPressableStyleOptions 25 | ) => ViewStyle 26 | ) => { 27 | return (props: CustomPressableProps) => { 28 | return ; 29 | }; 30 | }; 31 | 32 | export const createAnimatedPressable = ( 33 | animatedStyle: ( 34 | progress: number, 35 | options: AnimatedPressableStyleOptions 36 | ) => ViewStyle 37 | ) => { 38 | return withAnimatedTapStyle( 39 | BasePressable as React.ComponentType>, 40 | animatedStyle 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | merge_group: 10 | types: 11 | - checks_requested 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup 21 | uses: ./.github/actions/setup 22 | 23 | - name: Lint files 24 | run: bun run lint 25 | 26 | - name: Typecheck files 27 | run: bun run typecheck 28 | 29 | test: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | 35 | - name: Setup 36 | uses: ./.github/actions/setup 37 | 38 | - name: Run unit tests 39 | run: bun run test --maxWorkers=2 --coverage 40 | 41 | build-library: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v3 46 | 47 | - name: Setup 48 | uses: ./.github/actions/setup 49 | 50 | - name: Build package 51 | run: bun run prepare 52 | 53 | build-web: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v3 58 | 59 | - name: Setup 60 | uses: ./.github/actions/setup 61 | 62 | - name: Build example for Web 63 | run: | 64 | bun run example expo export --platform web 65 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pressto-example", 3 | "version": "1.0.0", 4 | "main": "expo-router/entry", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "lint": "eslint \"app/**/*.{ts,tsx}\"" 11 | }, 12 | "dependencies": { 13 | "@expo/metro-runtime": "~6.1.2", 14 | "expo": "54.0.13", 15 | "expo-constants": "~18.0.9", 16 | "expo-linking": "~8.0.8", 17 | "expo-router": "~6.0.12", 18 | "expo-status-bar": "~3.0.8", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "react-native": "0.81.4", 22 | "react-native-gesture-handler": "~2.28.0", 23 | "react-native-reanimated": "~4.1.1", 24 | "react-native-safe-area-context": "~5.6.0", 25 | "react-native-screens": "~4.16.0", 26 | "react-native-web": "^0.21.0", 27 | "react-native-worklets": "0.5.1" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.20.0", 31 | "@typescript-eslint/eslint-plugin": "^8.46.0", 32 | "@typescript-eslint/parser": "^8.46.0", 33 | "babel-preset-expo": "~54.0.0", 34 | "eslint": "^8.51.0", 35 | "eslint-config-prettier": "^9.0.0", 36 | "eslint-plugin-prettier": "^5.0.1", 37 | "eslint-plugin-react": "^7.37.5", 38 | "eslint-plugin-react-hooks": "^7.0.0", 39 | "prettier": "^3.0.3", 40 | "react-native-builder-bob": "^0.30.2", 41 | "typescript": "^5.2.2" 42 | }, 43 | "publishConfig": { 44 | "access": "public" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/provider/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { 3 | makeMutable, 4 | type SharedValue, 5 | type WithSpringConfig, 6 | type WithTimingConfig, 7 | } from 'react-native-reanimated'; 8 | import { DefaultAnimationConfigs, DefaultPressableConfig, type PressableConfig } from './constants'; 9 | 10 | export type AnimationType = 'timing' | 'spring'; 11 | 12 | export type AnimatedPressableOptions = { 13 | isPressed: boolean; 14 | isToggled: boolean; 15 | isSelected: boolean; 16 | }; 17 | 18 | export type PressableContextType< 19 | T extends AnimationType, 20 | TMetadata = unknown, 21 | > = { 22 | animationType: T; 23 | animationConfig: T extends 'timing' ? WithTimingConfig : WithSpringConfig; 24 | globalHandlers?: { 25 | onPressIn?: (options: AnimatedPressableOptions) => void; 26 | onPressOut?: (options: AnimatedPressableOptions) => void; 27 | onPress?: (options: AnimatedPressableOptions) => void; 28 | }; 29 | metadata?: TMetadata; 30 | /** 31 | * Activates the pressable animation on hover (web only) 32 | * @platform web 33 | */ 34 | activateOnHover?: boolean; 35 | /** 36 | * Pressable configuration values (opacity, scale, etc.) 37 | */ 38 | config: PressableConfig; 39 | }; 40 | 41 | export const PressablesContext = createContext< 42 | PressableContextType 43 | >({ 44 | animationType: 'timing', 45 | animationConfig: DefaultAnimationConfigs.timing, 46 | metadata: undefined, 47 | config: DefaultPressableConfig, 48 | }); 49 | 50 | export const PressablesGroupContext = createContext<{ 51 | lastTouchedPressable: SharedValue; 52 | }>({ 53 | lastTouchedPressable: makeMutable(null), 54 | }); 55 | -------------------------------------------------------------------------------- /example/app/screen.tsx: -------------------------------------------------------------------------------- 1 | import { createAnimatedPressable } from 'pressto'; 2 | import { StyleSheet } from 'react-native'; 3 | import { FlatList } from 'react-native-gesture-handler'; 4 | 5 | import { interpolate, interpolateColor } from 'react-native-reanimated'; 6 | 7 | const PressableRotate = createAnimatedPressable((progress) => { 8 | 'worklet'; 9 | return { 10 | transform: [ 11 | { rotate: `${(progress * Math.PI) / 4}rad` }, 12 | { scale: interpolate(progress, [0, 1], [1, 0.9]) }, 13 | ], 14 | backgroundColor: interpolateColor(progress, [0, 1], ['#d1d1d1', '#000000']), 15 | shadowColor: '#ffffff', 16 | shadowOffset: { 17 | width: 0, 18 | height: 0, 19 | }, 20 | shadowOpacity: interpolate(progress, [0, 1], [0, 1]), 21 | shadowRadius: interpolate(progress, [0, 1], [0, 150]), 22 | }; 23 | }); 24 | 25 | export default function App() { 26 | return ( 27 | { 31 | return ( 32 | { 34 | console.log('pressed'); 35 | }} 36 | style={styles.box} 37 | /> 38 | ); 39 | }} 40 | /> 41 | ); 42 | } 43 | 44 | const styles = StyleSheet.create({ 45 | container: { 46 | gap: 10, 47 | paddingTop: 15, 48 | }, 49 | box: { 50 | width: '95%', 51 | height: 100, 52 | elevation: 5, 53 | shadowColor: '#000000', 54 | shadowOffset: { 55 | width: 0, 56 | height: 0, 57 | }, 58 | shadowOpacity: 0.5, 59 | backgroundColor: 'red', 60 | shadowRadius: 10, 61 | borderRadius: 35, 62 | borderCurve: 'continuous', 63 | alignSelf: 'center', 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /src/provider/constants.ts: -------------------------------------------------------------------------------- 1 | import { Easing } from 'react-native-reanimated'; 2 | 3 | /** 4 | * Configuration values for pressable visual feedback 5 | */ 6 | export type PressableConfig = { 7 | /** 8 | * Target opacity when the pressable is in active/pressed state. 9 | * 10 | * Used by PressableOpacity to interpolate from 1 (idle) to this value (pressed). 11 | * 12 | * @default 0.5 13 | * @example 14 | * // More transparent when pressed 15 | * config={{ activeOpacity: 0.3 }} 16 | * 17 | * // Less transparent when pressed 18 | * config={{ activeOpacity: 0.7 }} 19 | */ 20 | activeOpacity: number; 21 | 22 | /** 23 | * Target scale when the pressable is in active/pressed state. 24 | * 25 | * Used by PressableScale to interpolate from baseScale (idle) to this value (pressed). 26 | * Values less than baseScale create a "shrink" effect. 27 | * 28 | * @default 0.96 29 | * @example 30 | * // More pronounced shrink effect 31 | * config={{ minScale: 0.9 }} 32 | * 33 | * // Subtle shrink effect 34 | * config={{ minScale: 0.98 }} 35 | */ 36 | minScale: number; 37 | 38 | /** 39 | * Base scale when the pressable is in idle/unpressed state. 40 | * 41 | * Used by PressableScale as the starting scale value. 42 | * Typically set to 1, but can be adjusted for special effects. 43 | * 44 | * @default 1 45 | * @example 46 | * // Normal size when idle 47 | * config={{ baseScale: 1 }} 48 | * 49 | * // Slightly enlarged when idle (grows when pressed if minScale > 1) 50 | * config={{ baseScale: 1.05, minScale: 1.1 }} 51 | */ 52 | baseScale: number; 53 | }; 54 | 55 | export const DefaultAnimationConfigs = { 56 | timing: { duration: 250, easing: Easing.bezier(0.25, 0.1, 0.25, 1) }, 57 | spring: { 58 | mass: 1, 59 | damping: 30, 60 | stiffness: 200, 61 | }, 62 | } as const; 63 | 64 | /** 65 | * Default configuration values for pressable visual feedback 66 | */ 67 | export const DefaultPressableConfig: PressableConfig = { 68 | activeOpacity: 0.5, 69 | minScale: 0.96, 70 | baseScale: 1, 71 | } as const; 72 | -------------------------------------------------------------------------------- /example/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'expo-router'; 2 | import { createAnimatedPressable } from 'pressto'; 3 | import { Platform, StyleSheet, Text, View } from 'react-native'; 4 | import { interpolate } from 'react-native-reanimated'; 5 | 6 | const PressableHighlight = createAnimatedPressable((progress) => { 7 | 'worklet'; 8 | const opacity = interpolate(progress, [0, 1], [0, 0.1]).toFixed(2); 9 | const scale = interpolate(progress, [0, 1], [1, 0.95]); 10 | 11 | return { 12 | backgroundColor: `rgba(255,255,255,${opacity})`, 13 | transform: [{ scale }], 14 | }; 15 | }); 16 | 17 | export default function Page() { 18 | const router = useRouter(); 19 | 20 | return ( 21 | 22 | router.navigate('/screen')} 24 | style={styles.button} 25 | > 26 | Rotation Example 27 | 28 | 29 | router.navigate('/options-example')} 31 | style={styles.button} 32 | > 33 | Toggle Options 34 | 35 | 36 | router.navigate('/metadata-example')} 38 | style={styles.button} 39 | > 40 | Theme Metadata 41 | 42 | {Platform.OS === 'web' && ( 43 | console.log("won't activate on hover")} 45 | style={styles.button} 46 | activateOnHover={false} 47 | > 48 | Won't activate on hover 49 | 50 | )} 51 | 52 | ); 53 | } 54 | 55 | const styles = StyleSheet.create({ 56 | container: { 57 | flex: 1, 58 | justifyContent: 'center', 59 | alignItems: 'center', 60 | backgroundColor: '#000000', 61 | gap: 20, 62 | }, 63 | button: { 64 | backgroundColor: 'black', 65 | height: 100, 66 | width: 200, 67 | borderRadius: 16, 68 | justifyContent: 'center', 69 | alignItems: 'center', 70 | }, 71 | buttonText: { 72 | color: 'white', 73 | fontSize: 16, 74 | fontWeight: 'bold', 75 | textAlign: 'center', 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /src/provider/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, type PropsWithChildren } from 'react'; 2 | import { 3 | useSharedValue, 4 | type WithSpringConfig, 5 | type WithTimingConfig, 6 | } from 'react-native-reanimated'; 7 | 8 | import { DefaultAnimationConfigs, DefaultPressableConfig, type PressableConfig } from './constants'; 9 | import { 10 | PressablesContext, 11 | PressablesGroupContext, 12 | type AnimatedPressableOptions, 13 | type AnimationType, 14 | } from './context'; 15 | 16 | export type PressablesConfigProps< 17 | T extends AnimationType, 18 | TMetadata = unknown, 19 | > = { 20 | children?: React.ReactNode; 21 | animationType?: T; 22 | animationConfig?: T extends 'timing' ? WithTimingConfig : WithSpringConfig; 23 | globalHandlers?: { 24 | onPressIn?: (options: AnimatedPressableOptions) => void; 25 | onPressOut?: (options: AnimatedPressableOptions) => void; 26 | onPress?: (options: AnimatedPressableOptions) => void; 27 | }; 28 | metadata?: TMetadata; 29 | /** 30 | * Activates the pressable animation on hover (web only) 31 | * @platform web 32 | */ 33 | activateOnHover?: boolean; 34 | /** 35 | * Pressable configuration values (opacity, scale, etc.) 36 | */ 37 | config?: Partial; 38 | }; 39 | 40 | export const PressablesGroup = ({ children }: PropsWithChildren) => { 41 | const lastTouchedPressable = useSharedValue(null); 42 | 43 | const groupValue = useMemo(() => { 44 | return { 45 | lastTouchedPressable: lastTouchedPressable, 46 | }; 47 | }, [lastTouchedPressable]); 48 | 49 | return ( 50 | 51 | {children} 52 | 53 | ); 54 | }; 55 | 56 | export const PressablesConfig = ({ 57 | children, 58 | animationType = 'timing' as T, 59 | animationConfig, 60 | globalHandlers, 61 | metadata, 62 | activateOnHover, 63 | config, 64 | }: PressablesConfigProps) => { 65 | const value = useMemo(() => { 66 | return { 67 | animationType, 68 | animationConfig: animationConfig ?? DefaultAnimationConfigs[animationType], 69 | globalHandlers, 70 | metadata, 71 | activateOnHover, 72 | config: { ...DefaultPressableConfig, ...config }, 73 | }; 74 | }, [animationType, animationConfig, globalHandlers, metadata, activateOnHover, config]); 75 | 76 | return ( 77 | 78 | {children} 79 | 80 | ); 81 | }; 82 | 83 | export * from './hooks'; 84 | export type { PressableConfig } from './constants'; 85 | -------------------------------------------------------------------------------- /eslint-plugin-pressto/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-pressto 2 | 3 | ESLint plugin for [pressto](https://github.com/enzomanuelmangano/pressto) - enforces best practices when using pressto's animated components. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install --save-dev eslint-plugin-pressto 9 | # or 10 | yarn add -D eslint-plugin-pressto 11 | # or 12 | bun add -D eslint-plugin-pressto 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### With ESLint Flat Config (`eslint.config.js`) 18 | 19 | ```javascript 20 | const presstoPlugin = require('eslint-plugin-pressto'); 21 | 22 | module.exports = [ 23 | { 24 | plugins: { 25 | pressto: presstoPlugin, 26 | }, 27 | rules: { 28 | 'pressto/require-worklet-directive': 'error', 29 | }, 30 | }, 31 | ]; 32 | ``` 33 | 34 | ### With Legacy Config (`.eslintrc.js`) 35 | 36 | ```javascript 37 | module.exports = { 38 | plugins: ['pressto'], 39 | rules: { 40 | 'pressto/require-worklet-directive': 'error', 41 | }, 42 | }; 43 | ``` 44 | 45 | ## Rules 46 | 47 | ### `pressto/require-worklet-directive` 48 | 49 | Enforces the use of `'worklet'` directive in functions passed to `createAnimatedPressable`. 50 | 51 | When using `createAnimatedPressable`, the animation function must run on the UI thread using React Native Reanimated. This requires a `'worklet'` directive as the first statement in the function body. 52 | 53 | #### Rule Details 54 | 55 | ❌ Examples of **incorrect** code: 56 | 57 | ```javascript 58 | const AnimatedButton = createAnimatedPressable(() => { 59 | // Missing 'worklet' directive 60 | return { 61 | opacity: withSpring(1), 62 | }; 63 | }); 64 | 65 | // Arrow function with implicit return (not supported) 66 | const AnimatedButton = createAnimatedPressable(() => ({ 67 | opacity: withSpring(1), 68 | })); 69 | ``` 70 | 71 | ✅ Examples of **correct** code: 72 | 73 | ```javascript 74 | const AnimatedButton = createAnimatedPressable(() => { 75 | 'worklet'; 76 | return { 77 | opacity: withSpring(1), 78 | }; 79 | }); 80 | 81 | const AnimatedButton = createAnimatedPressable(function() { 82 | 'worklet'; 83 | return { 84 | opacity: withSpring(1), 85 | }; 86 | }); 87 | ``` 88 | 89 | ## Why This Plugin? 90 | 91 | The `'worklet'` directive is required for functions that need to run on the UI thread in React Native Reanimated. Forgetting to add it can lead to runtime errors or unexpected behavior. This plugin helps catch these issues during development. 92 | 93 | ## Related 94 | 95 | - [pressto](https://github.com/enzomanuelmangano/pressto) - Custom React Native touchables with animations 96 | - [React Native Reanimated](https://docs.swmansion.com/react-native-reanimated/) - React Native's Animated library reimplemented 97 | 98 | ## License 99 | 100 | MIT 101 | -------------------------------------------------------------------------------- /eslint-plugin-pressto/rules/require-worklet-directive.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ESLint rule to enforce 'worklet' directive in createAnimatedPressable functions 3 | * @type {import('eslint').Rule.RuleModule} 4 | */ 5 | module.exports = { 6 | meta: { 7 | type: 'problem', 8 | docs: { 9 | description: 10 | "Enforce 'worklet' directive in functions passed to createAnimatedPressable", 11 | category: 'Best Practices', 12 | recommended: true, 13 | }, 14 | messages: { 15 | missingWorklet: 16 | "Missing 'worklet' directive in createAnimatedPressable function. Add 'worklet'; as the first statement in the function body.", 17 | }, 18 | schema: [], 19 | }, 20 | 21 | create(context) { 22 | return { 23 | CallExpression(node) { 24 | // Check if this is a call to createAnimatedPressable 25 | if ( 26 | node.callee.type === 'Identifier' && 27 | node.callee.name === 'createAnimatedPressable' 28 | ) { 29 | // Get the first argument (the animation function) 30 | const firstArg = node.arguments[0]; 31 | 32 | if (!firstArg) { 33 | return; 34 | } 35 | 36 | // Check if it's an arrow function or function expression 37 | if ( 38 | firstArg.type === 'ArrowFunctionExpression' || 39 | firstArg.type === 'FunctionExpression' 40 | ) { 41 | let body = firstArg.body; 42 | 43 | // If it's an arrow function with implicit return (no block), it's invalid 44 | if (firstArg.type === 'ArrowFunctionExpression' && body.type !== 'BlockStatement') { 45 | context.report({ 46 | node: firstArg, 47 | messageId: 'missingWorklet', 48 | }); 49 | return; 50 | } 51 | 52 | // Check if the function has a block statement 53 | if (body.type === 'BlockStatement') { 54 | const statements = body.body; 55 | 56 | if (statements.length === 0) { 57 | context.report({ 58 | node: firstArg, 59 | messageId: 'missingWorklet', 60 | }); 61 | return; 62 | } 63 | 64 | const firstStatement = statements[0]; 65 | 66 | // Check if the first statement is an expression statement with a 'worklet' directive 67 | if ( 68 | firstStatement.type === 'ExpressionStatement' && 69 | firstStatement.expression.type === 'Literal' && 70 | firstStatement.expression.value === 'worklet' 71 | ) { 72 | // Valid - has worklet directive 73 | return; 74 | } 75 | 76 | // Missing worklet directive 77 | context.report({ 78 | node: firstArg, 79 | messageId: 'missingWorklet', 80 | }); 81 | } 82 | } 83 | } 84 | }, 85 | }; 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /example/app/options-example.tsx: -------------------------------------------------------------------------------- 1 | import { createAnimatedPressable } from 'pressto'; 2 | import { StyleSheet, Text, View } from 'react-native'; 3 | import { interpolate, interpolateColor } from 'react-native-reanimated'; 4 | 5 | // Pressable that responds to all three option states 6 | const PressableToggle = createAnimatedPressable( 7 | (progress, { isPressed, isToggled, isSelected }) => { 8 | 'worklet'; 9 | 10 | // Base scale animation on press - uses progress AND isPressed 11 | const scale = interpolate(progress, [0, 1], [1, 0.95]); 12 | 13 | // Additional opacity change when actively pressed 14 | const opacity = isPressed ? 0.9 : 1; 15 | 16 | // Background color changes based on toggle state 17 | const backgroundColor = interpolateColor( 18 | progress, 19 | [0, 1], 20 | isToggled 21 | ? ['#4CAF50', '#388E3C'] // Green when toggled 22 | : ['#2196F3', '#1976D2'] // Blue when not toggled 23 | ); 24 | 25 | // Slight rotation when toggled 26 | const rotate = isToggled ? '5deg' : '0deg'; 27 | 28 | // Add a border for selected items 29 | const borderWidth = isSelected ? 3 : 0; 30 | const borderColor = '#FFD700'; // Gold border 31 | 32 | return { 33 | transform: [{ scale }, { rotate }], 34 | backgroundColor, 35 | borderWidth, 36 | borderColor, 37 | opacity, 38 | }; 39 | } 40 | ); 41 | 42 | export default function OptionsExample() { 43 | return ( 44 | 45 | Pressable Options Demo 46 | 47 | • isPressed: Active during press{'\n'}• isToggled: Flips on each press 48 | (green when toggled){'\n'}• isSelected: Gold border on last pressed 49 | button{'\n'}• Callbacks receive options object 50 | 51 | 52 | 53 | { 56 | console.log('Button 1 pressed:', options); 57 | }} 58 | > 59 | Button 1 60 | Logs options on press 61 | 62 | 63 | { 67 | console.log('Button 2 toggled to:', options.isToggled); 68 | }} 69 | > 70 | Button 2 (starts toggled) 71 | Logs toggle state 72 | 73 | 74 | { 77 | if (options.isSelected) { 78 | console.log('Button 3 is now selected!'); 79 | } 80 | }} 81 | > 82 | Button 3 83 | Logs when selected 84 | 85 | 86 | 87 | ); 88 | } 89 | 90 | const styles = StyleSheet.create({ 91 | container: { 92 | flex: 1, 93 | padding: 20, 94 | backgroundColor: '#000', 95 | }, 96 | title: { 97 | fontSize: 24, 98 | fontWeight: 'bold', 99 | color: '#fff', 100 | marginBottom: 8, 101 | }, 102 | subtitle: { 103 | fontSize: 14, 104 | color: '#aaa', 105 | marginBottom: 24, 106 | }, 107 | section: { 108 | gap: 12, 109 | }, 110 | item: { 111 | padding: 20, 112 | borderRadius: 16, 113 | }, 114 | itemText: { 115 | fontSize: 16, 116 | color: '#fff', 117 | fontWeight: '600', 118 | }, 119 | hint: { 120 | fontSize: 12, 121 | color: '#aaa', 122 | marginTop: 4, 123 | }, 124 | checkmark: { 125 | fontSize: 20, 126 | color: '#fff', 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are always welcome, no matter how large or small! 4 | 5 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. Before contributing, please read the [code of conduct](./CODE_OF_CONDUCT.md). 6 | 7 | ## Development workflow 8 | 9 | This project is a monorepo managed using [Bun workspaces](https://bun.sh/docs/install/workspaces). It contains the following packages: 10 | 11 | - The library package in the root directory. 12 | - An example app in the `example/` directory. 13 | 14 | To get started with the project, run `bun install` in the root directory to install the required dependencies for each package: 15 | 16 | ```sh 17 | bun install 18 | ``` 19 | 20 | > Since the project relies on Bun workspaces, you should use [`bun`](https://bun.sh) for development. 21 | 22 | The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. 23 | 24 | It is configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example app. Changes to the library's JavaScript code will be reflected in the example app without a rebuild, but native code changes will require a rebuild of the example app. 25 | 26 | You can use various commands from the root directory to work with the project. 27 | 28 | To start the packager: 29 | 30 | ```sh 31 | bun example start 32 | ``` 33 | 34 | To run the example app on Android: 35 | 36 | ```sh 37 | bun example android 38 | ``` 39 | 40 | To run the example app on iOS: 41 | 42 | ```sh 43 | bun example ios 44 | ``` 45 | 46 | To run the example app on Web: 47 | 48 | ```sh 49 | bun example web 50 | ``` 51 | 52 | Make sure your code passes TypeScript and ESLint. Run the following to verify: 53 | 54 | ```sh 55 | bun run typecheck 56 | bun run lint 57 | ``` 58 | 59 | To fix formatting errors, run the following: 60 | 61 | ```sh 62 | bun run lint --fix 63 | ``` 64 | 65 | Remember to add tests for your change if possible. Run the unit tests by: 66 | 67 | ```sh 68 | bun test 69 | ``` 70 | 71 | ### Commit message convention 72 | 73 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: 74 | 75 | - `fix`: bug fixes, e.g. fix crash due to deprecated method. 76 | - `feat`: new features, e.g. add new method to the module. 77 | - `refactor`: code refactor, e.g. migrate from class components to hooks. 78 | - `docs`: changes into documentation, e.g. add usage example for the module.. 79 | - `test`: adding or updating tests, e.g. add integration tests using detox. 80 | - `chore`: tooling changes, e.g. change CI config. 81 | 82 | Our pre-commit hooks verify that your commit message matches this format when committing. 83 | 84 | ### Linting and tests 85 | 86 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) 87 | 88 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. 89 | 90 | Our pre-commit hooks verify that the linter and tests pass when committing. 91 | 92 | ### Publishing to npm 93 | 94 | We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. 95 | 96 | To publish new versions, run the following: 97 | 98 | ```sh 99 | bun run release 100 | ``` 101 | 102 | ### Scripts 103 | 104 | The `package.json` file contains various scripts for common tasks: 105 | 106 | - `bun install`: setup project by installing dependencies. 107 | - `bun run typecheck`: type-check files with TypeScript. 108 | - `bun run lint`: lint files with ESLint. 109 | - `bun test`: run unit tests with Jest. 110 | - `bun example start`: start the Metro server for the example app. 111 | - `bun example android`: run the example app on Android. 112 | - `bun example ios`: run the example app on iOS. 113 | 114 | ### Sending a pull request 115 | 116 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). 117 | 118 | When you're sending a pull request: 119 | 120 | - Prefer small pull requests focused on one change. 121 | - Verify that linters and tests are passing. 122 | - Review the documentation to make sure it looks good. 123 | - Follow the pull request template when opening a pull request. 124 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pressto", 3 | "version": "0.6.0", 4 | "description": "Some custom react native touchables", 5 | "source": "./src/index.tsx", 6 | "main": "./lib/commonjs/index.js", 7 | "module": "./lib/module/index.js", 8 | "types": "./lib/typescript/module/src/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": { 12 | "types": "./lib/typescript/module/src/index.d.ts", 13 | "default": "./lib/module/index.js" 14 | }, 15 | "require": { 16 | "types": "./lib/typescript/commonjs/src/index.d.ts", 17 | "default": "./lib/commonjs/index.js" 18 | } 19 | } 20 | }, 21 | "files": [ 22 | "src", 23 | "lib", 24 | "!**/__tests__", 25 | "!**/__fixtures__", 26 | "!**/__mocks__", 27 | "!**/.*" 28 | ], 29 | "scripts": { 30 | "example": "bun --cwd example", 31 | "test": "jest", 32 | "typecheck": "tsc", 33 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 34 | "clean": "del-cli lib", 35 | "prepare": "bob build", 36 | "release": "release-it" 37 | }, 38 | "keywords": [ 39 | "react-native", 40 | "ios", 41 | "android", 42 | "pressable", 43 | "touchable", 44 | "gesture", 45 | "animation", 46 | "reanimated" 47 | ], 48 | "repository": { 49 | "type": "git", 50 | "url": "git+https://github.com/enzomanuelmangano/pressto.git" 51 | }, 52 | "author": "Enzo Manuel Mangano (https://github.com/enzomanuelmangano)", 53 | "license": "MIT", 54 | "bugs": { 55 | "url": "https://github.com/enzomanuelmangano/pressto/issues" 56 | }, 57 | "homepage": "https://github.com/enzomanuelmangano/pressto#readme", 58 | "publishConfig": { 59 | "registry": "https://registry.npmjs.org/" 60 | }, 61 | "devDependencies": { 62 | "@babel/eslint-parser": "^7.28.4", 63 | "@babel/preset-env": "^7.28.3", 64 | "@babel/preset-react": "^7.27.1", 65 | "@babel/preset-typescript": "^7.27.1", 66 | "@commitlint/config-conventional": "^17.0.2", 67 | "@evilmartians/lefthook": "^1.5.0", 68 | "@react-native/eslint-config": "^0.73.1", 69 | "@react-native/eslint-plugin": "^0.82.0", 70 | "@release-it/conventional-changelog": "^5.0.0", 71 | "@testing-library/jest-native": "^5.4.3", 72 | "@testing-library/react-native": "^13.3.3", 73 | "@types/jest": "^29.5.5", 74 | "@types/minimatch": "^6.0.0", 75 | "@types/react": "^18.2.44", 76 | "@typescript-eslint/eslint-plugin": "^8.46.0", 77 | "@typescript-eslint/parser": "^8.46.0", 78 | "commitlint": "^17.0.2", 79 | "del-cli": "^5.1.0", 80 | "eslint": "^8.51.0", 81 | "eslint-config-prettier": "^9.0.0", 82 | "eslint-plugin-eslint-comments": "^3.2.0", 83 | "eslint-plugin-ft-flow": "^3.0.11", 84 | "eslint-plugin-jest": "^29.0.1", 85 | "eslint-plugin-pressto": "workspace:eslint-plugin-pressto", 86 | "eslint-plugin-prettier": "^5.0.1", 87 | "eslint-plugin-react": "^7.37.5", 88 | "eslint-plugin-react-hooks": "^7.0.0", 89 | "eslint-plugin-react-native": "^5.0.0", 90 | "jest": "^29.7.0", 91 | "prettier": "^3.0.3", 92 | "react": "19.1.0", 93 | "react-native": "0.81.4", 94 | "react-native-builder-bob": "^0.30.2", 95 | "react-native-gesture-handler": "~2.28.0", 96 | "react-native-reanimated": "~4.1.1", 97 | "react-native-worklets": "0.5.1", 98 | "react-test-renderer": "^19.2.0", 99 | "release-it": "^15.0.0", 100 | "typescript": "^5.2.2" 101 | }, 102 | "resolutions": { 103 | "@types/react": "^18.2.44" 104 | }, 105 | "peerDependencies": { 106 | "react": "*", 107 | "react-native": "*", 108 | "react-native-gesture-handler": "*", 109 | "react-native-reanimated": "*", 110 | "react-native-worklets": "*" 111 | }, 112 | "workspaces": [ 113 | "example", 114 | "eslint-plugin-pressto" 115 | ], 116 | "packageManager": "bun@1.2.19", 117 | "commitlint": { 118 | "extends": [ 119 | "@commitlint/config-conventional" 120 | ] 121 | }, 122 | "release-it": { 123 | "git": { 124 | "commitMessage": "chore: release ${version}", 125 | "tagName": "v${version}" 126 | }, 127 | "npm": { 128 | "publish": true 129 | }, 130 | "github": { 131 | "release": true 132 | }, 133 | "plugins": { 134 | "@release-it/conventional-changelog": { 135 | "preset": "angular" 136 | } 137 | } 138 | }, 139 | "eslintIgnore": [ 140 | "node_modules/", 141 | "lib/" 142 | ], 143 | "prettier": { 144 | "quoteProps": "consistent", 145 | "singleQuote": true, 146 | "tabWidth": 2, 147 | "trailingComma": "es5", 148 | "useTabs": false 149 | }, 150 | "react-native-builder-bob": { 151 | "source": "src", 152 | "output": "lib", 153 | "targets": [ 154 | [ 155 | "commonjs", 156 | { 157 | "esm": true 158 | } 159 | ], 160 | [ 161 | "module", 162 | { 163 | "esm": true 164 | } 165 | ], 166 | [ 167 | "typescript", 168 | { 169 | "project": "tsconfig.build.json", 170 | "esm": true 171 | } 172 | ] 173 | ] 174 | }, 175 | "create-react-native-library": { 176 | "type": "library", 177 | "version": "0.41.2" 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /example/app/metadata-example.tsx: -------------------------------------------------------------------------------- 1 | import { createAnimatedPressable, PressablesConfig } from 'pressto'; 2 | import { StyleSheet, Text, View } from 'react-native'; 3 | import { interpolate, interpolateColor } from 'react-native-reanimated'; 4 | 5 | // Define your app's theme/design system 6 | type AppTheme = { 7 | colors: { 8 | primary: string; 9 | primaryDark: string; 10 | secondary: string; 11 | secondaryDark: string; 12 | background: string; 13 | text: string; 14 | }; 15 | spacing: { 16 | small: number; 17 | medium: number; 18 | large: number; 19 | }; 20 | borderRadius: { 21 | small: number; 22 | medium: number; 23 | large: number; 24 | }; 25 | }; 26 | 27 | const theme: AppTheme = { 28 | colors: { 29 | primary: '#6366F1', 30 | primaryDark: '#4F46E5', 31 | secondary: '#EC4899', 32 | secondaryDark: '#DB2777', 33 | background: '#0F172A', 34 | text: '#F1F5F9', 35 | }, 36 | spacing: { 37 | small: 8, 38 | medium: 16, 39 | large: 24, 40 | }, 41 | borderRadius: { 42 | small: 8, 43 | medium: 12, 44 | large: 16, 45 | }, 46 | }; 47 | 48 | // Create themed pressable that uses metadata from context 49 | const ThemedPrimaryButton = createAnimatedPressable( 50 | (progress, { isPressed, isToggled, metadata }) => { 51 | 'worklet'; 52 | 53 | const scale = interpolate(progress, [0, 1], [1, 0.96]); 54 | 55 | const backgroundColor = interpolateColor( 56 | progress, 57 | [0, 1], 58 | [metadata.colors.primary, metadata.colors.primaryDark] 59 | ); 60 | 61 | // Subtle elevation effect when toggled 62 | const shadowOpacity = isToggled ? 0.3 : 0.15; 63 | 64 | return { 65 | transform: [{ scale }], 66 | backgroundColor, 67 | borderRadius: metadata.borderRadius.medium, 68 | padding: metadata.spacing.medium, 69 | // Dim when pressed 70 | opacity: isPressed ? 0.9 : 1, 71 | shadowOpacity, 72 | shadowRadius: 8, 73 | shadowColor: '#000', 74 | shadowOffset: { width: 0, height: 4 }, 75 | }; 76 | } 77 | ); 78 | 79 | const ThemedSecondaryButton = createAnimatedPressable( 80 | (progress, { isToggled, metadata }) => { 81 | 'worklet'; 82 | 83 | const scale = interpolate(progress, [0, 1], [1, 0.94]); 84 | 85 | // Toggle between secondary colors 86 | const backgroundColor = isToggled 87 | ? metadata.colors.secondary 88 | : metadata.colors.secondaryDark; 89 | 90 | // Rotate slightly when toggled 91 | const rotate = isToggled ? '3deg' : '0deg'; 92 | 93 | return { 94 | transform: [{ scale }, { rotate }], 95 | backgroundColor, 96 | borderRadius: metadata.borderRadius.large, 97 | padding: metadata.spacing.large, 98 | }; 99 | } 100 | ); 101 | 102 | const ThemedCard = createAnimatedPressable( 103 | (progress, { isSelected, metadata }) => { 104 | 'worklet'; 105 | 106 | const scale = interpolate(progress, [0, 1], [1, 0.98]); 107 | 108 | return { 109 | transform: [{ scale }], 110 | backgroundColor: metadata.colors.background, 111 | borderRadius: metadata.borderRadius.large, 112 | padding: metadata.spacing.large, 113 | borderWidth: isSelected ? 2 : 1, 114 | borderColor: isSelected 115 | ? metadata.colors.primary 116 | : metadata.colors.primaryDark, 117 | }; 118 | } 119 | ); 120 | 121 | export default function MetadataExample() { 122 | return ( 123 | // Pass your theme as metadata - it will be available in all pressables! 124 | 125 | 126 | Metadata Theme Example 127 | 128 | All buttons access theme values from PressablesConfig metadata 129 | 130 | 131 | 132 | console.log('primary')}> 133 | Primary Button 134 | Uses theme.colors.primary 135 | 136 | 137 | console.log('secondary')}> 138 | Secondary (Toggle) 139 | 140 | Toggles between theme secondary colors 141 | 142 | 143 | 144 | console.log('card 1')}> 145 | Themed Card 1 146 | Selected shows primary border 147 | 148 | 149 | console.log('card 2')}> 150 | Themed Card 2 151 | Last pressed is selected 152 | 153 | 154 | 155 | 156 | ); 157 | } 158 | 159 | const styles = StyleSheet.create({ 160 | container: { 161 | flex: 1, 162 | padding: 20, 163 | backgroundColor: '#0F172A', 164 | }, 165 | title: { 166 | fontSize: 24, 167 | fontWeight: 'bold', 168 | color: '#F1F5F9', 169 | marginBottom: 8, 170 | }, 171 | subtitle: { 172 | fontSize: 14, 173 | color: '#94A3B8', 174 | marginBottom: 24, 175 | }, 176 | section: { 177 | gap: 16, 178 | }, 179 | buttonText: { 180 | fontSize: 16, 181 | color: '#FFF', 182 | fontWeight: '600', 183 | textAlign: 'center', 184 | }, 185 | hint: { 186 | fontSize: 12, 187 | color: 'rgba(255,255,255,0.7)', 188 | marginTop: 4, 189 | textAlign: 'center', 190 | }, 191 | cardText: { 192 | fontSize: 16, 193 | color: '#F1F5F9', 194 | fontWeight: '600', 195 | }, 196 | }); 197 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [INSERT CONTACT METHOD]. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Pressto 🔥 2 | 3 | https://github.com/user-attachments/assets/c857eb8d-3ce7-4afe-b2dd-e974560684d8 4 | 5 | **Replace TouchableOpacity with animated pressables that run on the main thread.** 6 | 7 | Built on [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/) and [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) for 60fps animations. 8 | 9 | ## Installation 10 | 11 | ```sh 12 | bun add pressto react-native-reanimated react-native-gesture-handler react-native-worklets 13 | ``` 14 | 15 | ## Quickstart 16 | 17 | ```jsx 18 | import { PressableScale } from 'pressto'; 19 | 20 | function App() { 21 | return ( 22 | console.log('pressed')}> 23 | Press me 24 | 25 | ); 26 | } 27 | ``` 28 | 29 | **That's it.** Your pressable now scales smoothly on press with main-thread animations. 30 | 31 | --- 32 | 33 | ## Basic Usage 34 | 35 | ### Pre-built Components 36 | 37 | Pressto comes with two ready-to-use components: 38 | 39 | **PressableScale** - Scales down when pressed 40 | 41 | ```jsx 42 | import { PressableScale } from 'pressto'; 43 | 44 | alert('Pressed!')}> 45 | Scale Animation 46 | ; 47 | ``` 48 | 49 | **PressableOpacity** - Fades when pressed 50 | 51 | ```jsx 52 | import { PressableOpacity } from 'pressto'; 53 | 54 | alert('Pressed!')}> 55 | Opacity Animation 56 | ; 57 | ``` 58 | 59 | Both components accept all standard React Native Pressable props (`onPress`, `onPressIn`, `onPressOut`, `style`, etc.). 60 | 61 | --- 62 | 63 | ## Custom Animations 64 | 65 | ### Create Your Own Pressable 66 | 67 | Use `createAnimatedPressable` to create custom animations: 68 | 69 | ```jsx 70 | import { createAnimatedPressable } from 'pressto'; 71 | 72 | const PressableRotate = createAnimatedPressable((progress) => { 73 | 'worklet'; 74 | return { 75 | transform: [{ rotate: `${progress * 45}deg` }], 76 | }; 77 | }); 78 | 79 | // Use it like any other pressable 80 | console.log('rotated!')}> 81 | Rotate Me 82 | ; 83 | ``` 84 | 85 | **The `progress` parameter** goes from `0` (idle) to `1` (pressed), allowing you to interpolate any style property. 86 | 87 | > **⚠️ Important:** The `'worklet';` directive is **required** at the start of your animation function. Without it, animations won't run on the UI thread. 88 | > 89 | > **Tip:** Install [eslint-plugin-pressto](https://github.com/enzomanuelmangano/pressto/tree/main/eslint-plugin-pressto) to catch missing `'worklet'` directives at development time. 90 | 91 | --- 92 | 93 | ## Configuration 94 | 95 | Use `PressablesConfig` to customize animation behavior for all pressables in your app: 96 | 97 | ```jsx 98 | import { PressablesConfig, PressableScale } from 'pressto'; 99 | 100 | function App() { 101 | return ( 102 | 107 | console.log('pressed')}> 108 | Now with spring animation! 109 | 110 | 111 | ); 112 | } 113 | ``` 114 | 115 | **Options:** 116 | 117 | - `animationType`: `'timing'` or `'spring'` (default: `'timing'`) 118 | - `animationConfig`: Pass timing or spring configuration 119 | - `config`: Set default values for `minScale`, `activeOpacity`, `baseScale` 120 | 121 | ### Global Handlers 122 | 123 | Add global handlers like haptic feedback: 124 | 125 | ```jsx 126 | import { PressablesConfig } from 'pressto'; 127 | import * as Haptics from 'expo-haptics'; 128 | 129 | function App() { 130 | return ( 131 | { 134 | Haptics.selectionAsync(); 135 | }, 136 | }} 137 | > 138 | {/* All pressables will trigger haptics */} 139 | 140 | 141 | ); 142 | } 143 | ``` 144 | 145 | --- 146 | 147 | ## Advanced Features 148 | 149 | ### Interaction States 150 | 151 | Access advanced state in your custom pressables: 152 | 153 | ```jsx 154 | const ToggleButton = createAnimatedPressable((progress, options) => { 155 | 'worklet'; 156 | 157 | const { isPressed, isToggled, isSelected } = options; 158 | // isPressed: true while actively pressing 159 | // isToggled: toggles on each press (persistent) 160 | // isSelected: true for last pressed item in a group 161 | 162 | return { 163 | backgroundColor: isToggled ? '#4CAF50' : '#2196F3', 164 | opacity: isPressed ? 0.8 : 1, 165 | borderWidth: isSelected ? 3 : 0, 166 | }; 167 | }); 168 | ``` 169 | 170 | ### Theme Metadata 171 | 172 | Pass your design system into worklets with type safety: 173 | 174 | ```jsx 175 | const theme = { 176 | colors: { primary: '#6366F1' }, 177 | spacing: { medium: 16 }, 178 | }; 179 | 180 | type Theme = typeof theme; 181 | 182 | const ThemedButton = createAnimatedPressable((progress, { metadata }) => { 183 | 'worklet'; 184 | return { 185 | backgroundColor: metadata.colors.primary, 186 | padding: metadata.spacing.medium, 187 | }; 188 | }); 189 | 190 | 191 | {}} /> 192 | 193 | ``` 194 | 195 | ### Web Hover Support 196 | 197 | Activate animations on hover (web only): 198 | 199 | ```jsx 200 | // Per component 201 | {}}> 202 | Hover me! 203 | 204 | 205 | // Or globally 206 | 207 | 208 | 209 | ``` 210 | 211 | ### Avoid highlight flicker effect in Scrollable List 212 | 213 | Since pressto is built on top of the BaseButton from react-native-gesture-handler, it handles tap conflict detection automatically when used with a FlatList imported from react-native-gesture-handler. 214 | 215 | ```jsx 216 | import { FlatList } from 'react-native-gesture-handler'; 217 | import { PressableScale } from 'pressto'; 218 | 219 | function App() { 220 | return ( 221 | ( 224 | console.log(item)}> 225 | {item.title} 226 | 227 | )} 228 | /> 229 | ); 230 | } 231 | ``` 232 | 233 | You can also use whatever Scrollable component you want, as long as it supports the renderScrollComponent prop. 234 | 235 | ```jsx 236 | import { WhateverList } from 'your-favorite-list-package' 237 | import { ScrollView } from 'react-native-gesture-handler'; 238 | import { PressableScale } from 'pressto'; 239 | 240 | function App() { 241 | return ( 242 | ( 245 | console.log(item)}> 246 | {item.title} 247 | 248 | )} 249 | renderScrollComponent={(props) => } 250 | /> 251 | ); 252 | } 253 | ``` 254 | 255 | --- 256 | 257 | ## API Reference 258 | 259 | ### `createAnimatedPressable(animatedStyle)` 260 | 261 | Creates a custom animated pressable. 262 | 263 | **Parameters:** 264 | 265 | - `animatedStyle`: Function that returns animated styles 266 | - `progress`: number (0-1) - Animation progress 267 | - `options.isPressed`: boolean - Currently being pressed 268 | - `options.isToggled`: boolean - Toggle state (persistent) 269 | - `options.isSelected`: boolean - Selected in group 270 | - `options.metadata`: TMetadata - Custom theme data 271 | - `options.config`: PressableConfig - Default values (minScale, activeOpacity, baseScale) 272 | 273 | ### `PressablesConfig` 274 | 275 | Global configuration provider. 276 | 277 | **Props:** 278 | 279 | - `animationType`: 'timing' | 'spring' - Default: 'timing' 280 | - `animationConfig`: Timing/spring config object 281 | - `config`: { activeOpacity, minScale, baseScale } 282 | - `globalHandlers`: { onPress, onPressIn, onPressOut } 283 | - `metadata`: Custom theme/config (type-safe) 284 | - `activateOnHover`: boolean - Web only 285 | 286 | ### `PressableConfig` 287 | 288 | Default animation values: 289 | 290 | ```typescript 291 | { 292 | activeOpacity: 0.5, // PressableOpacity target 293 | minScale: 0.96, // PressableScale target 294 | baseScale: 1 // PressableScale idle scale 295 | } 296 | ``` 297 | 298 | --- 299 | 300 | ## Migration Guide 301 | 302 | ### v0.5.1 → v0.6.0 303 | 304 | `PressablesConfig` prop renamed: `config` → `animationConfig` 305 | 306 | ```diff 307 | 311 | ``` 312 | 313 | ### v0.3.x → v0.5.x 314 | 315 | `progress` is now a plain `number` instead of `SharedValue`: 316 | 317 | ```diff 318 | - opacity: progress.get() * 0.5, 319 | + opacity: progress * 0.5, 320 | ``` 321 | 322 | --- 323 | 324 | ## Contributing 325 | 326 | See [CONTRIBUTING.md](CONTRIBUTING.md) 327 | 328 | ## License 329 | 330 | MIT 331 | 332 | --- 333 | 334 | Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) 335 | -------------------------------------------------------------------------------- /src/pressables/base.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useId, useMemo, type ComponentProps } from 'react'; 2 | import { Platform, type ViewStyle } from 'react-native'; 3 | import { BaseButton } from 'react-native-gesture-handler'; 4 | import Animated, { 5 | useAnimatedStyle, 6 | useDerivedValue, 7 | useSharedValue, 8 | withSpring, 9 | withTiming, 10 | type AnimatableValue, 11 | type SharedValue, 12 | } from 'react-native-reanimated'; 13 | import { useLastTouchedPressable, usePressablesConfig } from '../provider'; 14 | import type { PressableConfig } from '../provider/constants'; 15 | import type { 16 | AnimatedPressableOptions, 17 | PressableContextType, 18 | } from '../provider/context'; 19 | 20 | const AnimatedBaseButton = Animated.createAnimatedComponent(BaseButton); 21 | type AnimatedPressableProps = ComponentProps; 22 | 23 | export type AnimatedPressableStyleOptions = { 24 | isPressed: boolean; 25 | isToggled: boolean; 26 | isSelected: boolean; 27 | metadata: TMetadata; 28 | config: PressableConfig; 29 | /** 30 | * Pre-configured animation function (withTiming or withSpring with config already applied) 31 | * Supports numbers, strings (colors), and other animatable values 32 | * @example 33 | * const opacity = withAnimation(isToggled ? 0.5 : 1); 34 | * const backgroundColor = withAnimation(isPressed ? '#ff0000' : '#0000ff'); 35 | */ 36 | withAnimation: (value: T) => T; 37 | }; 38 | 39 | export type PressableChildrenCallbackParams = { 40 | /** 41 | * Animation progress from 0 (idle) to 1 (pressed) 42 | */ 43 | progress: SharedValue; 44 | /** 45 | * Whether the pressable is currently being pressed 46 | */ 47 | isPressed: SharedValue; 48 | /** 49 | * Toggle state - flips on each press 50 | */ 51 | isToggled: SharedValue; 52 | /** 53 | * Whether this pressable is the last one pressed in the group 54 | */ 55 | isSelected: SharedValue; 56 | /** 57 | * Pre-configured animation function (withTiming or withSpring with config already applied) 58 | * Supports numbers, strings (colors), and other animatable values 59 | * @example 60 | * const opacity = useDerivedValue(() => withAnimation(isPressed.value ? 0.5 : 1)); 61 | * const backgroundColor = useDerivedValue(() => withAnimation(isPressed.value ? '#ff0000' : '#0000ff')); 62 | */ 63 | withAnimation: (value: T) => T; 64 | }; 65 | 66 | export type BasePressableProps = { 67 | children?: 68 | | React.ReactNode 69 | | ((params: PressableChildrenCallbackParams) => React.ReactNode); 70 | animatedStyle?: ( 71 | progress: number, 72 | options: AnimatedPressableStyleOptions 73 | ) => ViewStyle; 74 | enabled?: boolean; 75 | initialToggled?: boolean; 76 | /** 77 | * Activates the pressable animation on hover (web only) 78 | * @platform web 79 | */ 80 | activateOnHover?: boolean; 81 | } & Omit< 82 | Partial>, 83 | 'metadata' | 'config' 84 | > & 85 | Partial< 86 | Pick< 87 | AnimatedPressableProps, 88 | | 'layout' 89 | | 'onLongPress' 90 | | 'entering' 91 | | 'exiting' 92 | | 'style' 93 | | 'hitSlop' 94 | | 'testID' 95 | | 'userSelect' 96 | | 'activeCursor' 97 | | 'shouldCancelWhenOutside' 98 | | 'cancelsTouchesInView' 99 | | 'enableContextMenu' 100 | | 'rippleColor' 101 | | 'rippleRadius' 102 | | 'touchSoundDisabled' 103 | | 'waitFor' 104 | | 'simultaneousHandlers' 105 | | 'accessibilityHint' 106 | | 'accessibilityLabel' 107 | | 'accessibilityRole' 108 | | 'accessibilityState' 109 | | 'accessibilityValue' 110 | | 'accessibilityActions' 111 | > 112 | > & { 113 | onPress?: (options: AnimatedPressableOptions) => void; 114 | onPressIn?: (options: AnimatedPressableOptions) => void; 115 | onPressOut?: (options: AnimatedPressableOptions) => void; 116 | }; 117 | 118 | const cursorStyle = Platform.OS === 'web' ? { cursor: 'pointer' as const } : {}; 119 | 120 | const BasePressable: React.FC = React.memo( 121 | ({ 122 | children, 123 | onPress, 124 | onPressIn, 125 | onPressOut, 126 | animatedStyle, 127 | animationType: animationTypeProp, 128 | animationConfig: animationConfigProp, 129 | enabled = true, 130 | initialToggled = false, 131 | activateOnHover: activateOnHoverProp, 132 | ...rest 133 | }) => { 134 | const { 135 | animationType: animationTypeProvider, 136 | animationConfig: animationConfigProvider, 137 | globalHandlers, 138 | metadata, 139 | activateOnHover: activateOnHoverProvider, 140 | config, 141 | } = usePressablesConfig(); 142 | 143 | const lastTouchedPressable = useLastTouchedPressable(); 144 | const pressableId = useId(); 145 | 146 | const { 147 | onPressIn: onPressInProvider, 148 | onPressOut: onPressOutProvider, 149 | onPress: onPressProvider, 150 | } = globalHandlers ?? {}; 151 | 152 | const active = useSharedValue(false); 153 | const isToggled = useSharedValue(initialToggled); 154 | 155 | const { animationType, animationConfig } = useMemo(() => { 156 | if (animationTypeProp != null) { 157 | return { 158 | animationType: animationTypeProp, 159 | animationConfig: animationConfigProp, 160 | }; 161 | } 162 | return { 163 | animationType: animationTypeProvider, 164 | animationConfig: animationConfigProp ?? animationConfigProvider, 165 | }; 166 | }, [ 167 | animationTypeProp, 168 | animationTypeProvider, 169 | animationConfigProp, 170 | animationConfigProvider, 171 | ]); 172 | 173 | const withAnimation = useMemo(() => { 174 | return animationType === 'timing' ? withTiming : withSpring; 175 | }, [animationType]); 176 | 177 | // Pre-configured animation function for children (config already applied) 178 | const withAnimationConfigured = useMemo(() => { 179 | return (value: T): T => { 180 | 'worklet'; 181 | return withAnimation(value, animationConfig) as T; 182 | }; 183 | }, [withAnimation, animationConfig]); 184 | 185 | const progress = useDerivedValue(() => { 186 | return withAnimationConfigured(active.get() ? 1 : 0); 187 | }, [withAnimationConfigured]); 188 | 189 | // Derived SharedValue for isSelected (computed from comparison) 190 | const isSelectedDerived = useDerivedValue(() => { 191 | return lastTouchedPressable.get() === pressableId; 192 | }, [lastTouchedPressable, pressableId]); 193 | 194 | const onPressInWrapper = useCallback(() => { 195 | active.set(true); 196 | const options: AnimatedPressableOptions = { 197 | isPressed: active.get(), 198 | isToggled: isToggled.get(), 199 | isSelected: lastTouchedPressable.get() === pressableId, 200 | }; 201 | onPressInProvider?.(options); 202 | onPressIn?.(options); 203 | }, [ 204 | active, 205 | onPressIn, 206 | onPressInProvider, 207 | isToggled, 208 | lastTouchedPressable, 209 | pressableId, 210 | ]); 211 | 212 | const onPressWrapper = useCallback(() => { 213 | active.set(false); 214 | isToggled.set(!isToggled.get()); 215 | lastTouchedPressable.set(pressableId); 216 | const options: AnimatedPressableOptions = { 217 | isPressed: active.get(), 218 | isToggled: isToggled.get(), 219 | isSelected: lastTouchedPressable.get() === pressableId, 220 | }; 221 | onPressProvider?.(options); 222 | onPress?.(options); 223 | }, [ 224 | active, 225 | onPress, 226 | onPressProvider, 227 | isToggled, 228 | lastTouchedPressable, 229 | pressableId, 230 | ]); 231 | 232 | const onPressOutWrapper = useCallback(() => { 233 | active.set(false); 234 | const options: AnimatedPressableOptions = { 235 | isPressed: active.get(), 236 | isToggled: isToggled.get(), 237 | isSelected: lastTouchedPressable.get() === pressableId, 238 | }; 239 | onPressOutProvider?.(options); 240 | onPressOut?.(options); 241 | }, [ 242 | active, 243 | onPressOut, 244 | onPressOutProvider, 245 | isToggled, 246 | lastTouchedPressable, 247 | pressableId, 248 | ]); 249 | 250 | // Determine if hover should be enabled (web only) 251 | const shouldEnableHover = 252 | Platform.OS === 'web' && 253 | (activateOnHoverProp ?? activateOnHoverProvider ?? false); 254 | 255 | // Hover handlers for web 256 | const onMouseEnter = useCallback(() => { 257 | if (shouldEnableHover && enabled) { 258 | active.set(true); 259 | } 260 | }, [shouldEnableHover, enabled, active]); 261 | 262 | const onMouseLeave = useCallback(() => { 263 | if (shouldEnableHover) { 264 | active.set(false); 265 | } 266 | }, [shouldEnableHover, active]); 267 | 268 | const rAnimatedStyle = useAnimatedStyle(() => { 269 | return animatedStyle 270 | ? animatedStyle(progress.get(), { 271 | isPressed: active.get(), 272 | isToggled: isToggled.get(), 273 | isSelected: lastTouchedPressable.get() === pressableId, 274 | metadata, 275 | config, 276 | withAnimation: withAnimationConfigured, 277 | }) 278 | : {}; 279 | }, [ 280 | animatedStyle, 281 | progress, 282 | active, 283 | isToggled, 284 | lastTouchedPressable, 285 | pressableId, 286 | metadata, 287 | config, 288 | withAnimationConfigured, 289 | ]); 290 | 291 | const hoverProps = useMemo( 292 | () => 293 | shouldEnableHover 294 | ? { 295 | onMouseEnter, 296 | onMouseLeave, 297 | } 298 | : {}, 299 | [shouldEnableHover, onMouseEnter, onMouseLeave] 300 | ); 301 | 302 | const childrenCallbackParams = useMemo( 303 | () => ({ 304 | progress, 305 | isPressed: active, 306 | isToggled, 307 | isSelected: isSelectedDerived, 308 | withAnimation: withAnimationConfigured, 309 | }), 310 | [progress, active, isToggled, isSelectedDerived, withAnimationConfigured] 311 | ); 312 | 313 | const renderedChildren = useMemo(() => { 314 | if (typeof children === 'function') { 315 | return children(childrenCallbackParams); 316 | } 317 | return children; 318 | }, [children, childrenCallbackParams]); 319 | 320 | return ( 321 | 334 | {renderedChildren} 335 | 336 | ); 337 | } 338 | ); 339 | 340 | BasePressable.displayName = 'BasePressable'; 341 | 342 | export { BasePressable }; 343 | --------------------------------------------------------------------------------