├── .gitignore ├── README.md ├── app.json ├── app ├── _layout.tsx └── index.tsx ├── assets ├── fonts │ └── AirbnbCereal_Bold.otf └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ ├── partial-react-logo.png │ ├── react-logo.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── splash-icon.png ├── package.json ├── pnpm-lock.yaml ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Implementation of the Airbnb month slider in React Native. 2 | 3 | ![image](https://github.com/user-attachments/assets/c24f52b4-eaa9-4928-9fac-ba633d005ee0) 4 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "airbnb-slider", 4 | "slug": "airbnb-slider", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true 13 | }, 14 | "android": { 15 | "adaptiveIcon": { 16 | "foregroundImage": "./assets/images/adaptive-icon.png", 17 | "backgroundColor": "#ffffff" 18 | } 19 | }, 20 | "web": { 21 | "bundler": "metro", 22 | "output": "static", 23 | "favicon": "./assets/images/favicon.png" 24 | }, 25 | "plugins": [ 26 | "expo-router", 27 | [ 28 | "expo-splash-screen", 29 | { 30 | "image": "./assets/images/splash-icon.png", 31 | "imageWidth": 200, 32 | "resizeMode": "contain", 33 | "backgroundColor": "#ffffff" 34 | } 35 | ], 36 | [ 37 | "expo-font", 38 | { 39 | "fonts": ["assets/fonts/AirbnbCereal-Bold.otf"] 40 | } 41 | ] 42 | ], 43 | "experiments": { 44 | "typedRoutes": true 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { useFonts } from 'expo-font'; 2 | import { Stack } from 'expo-router'; 3 | import * as SplashScreen from 'expo-splash-screen'; 4 | import { StatusBar } from 'expo-status-bar'; 5 | import { useEffect } from 'react'; 6 | import 'react-native-reanimated'; 7 | 8 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 9 | 10 | // Prevent the splash screen from auto-hiding before asset loading is complete. 11 | SplashScreen.preventAutoHideAsync(); 12 | 13 | export default function RootLayout() { 14 | const [loaded] = useFonts({ 15 | AirbnbCereal: require('../assets/fonts/AirbnbCereal_Bold.otf'), 16 | }); 17 | 18 | useEffect(() => { 19 | if (loaded) { 20 | SplashScreen.hideAsync(); 21 | } 22 | }, [loaded]); 23 | 24 | if (!loaded) { 25 | return null; 26 | } 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useMemo, memo, useCallback } from 'react'; 2 | import { 3 | StyleSheet, 4 | View, 5 | Text, 6 | Dimensions, 7 | useWindowDimensions, 8 | } from 'react-native'; 9 | import { Gesture, GestureDetector } from 'react-native-gesture-handler'; 10 | import { 11 | useSharedValue, 12 | useDerivedValue, 13 | runOnJS, 14 | withTiming, 15 | } from 'react-native-reanimated'; 16 | import { 17 | Canvas, 18 | Circle, 19 | Group, 20 | Path, 21 | usePathValue, 22 | vec, 23 | LinearGradient, 24 | RadialGradient, 25 | Shadow, 26 | Paint, 27 | Blur, 28 | Mask, 29 | } from '@shopify/react-native-skia'; 30 | 31 | // Constants for the slider dimensions and appearance 32 | const { width } = Dimensions.get('window'); 33 | const CIRCLE_SIZE = width * 0.8; 34 | const CIRCLE_STROKE_WIDTH = 70; 35 | const INNER_CIRCLE_STROKE_WIDTH = CIRCLE_STROKE_WIDTH * 0.85; 36 | const CIRCLE_RADIUS = (CIRCLE_SIZE - CIRCLE_STROKE_WIDTH) / 2; 37 | const DOT_SIZE = 4; 38 | const KNOB_SIZE = (INNER_CIRCLE_STROKE_WIDTH / 2) * 0.82; 39 | const CENTER = CIRCLE_SIZE / 2; 40 | const MAX_MONTHS = 12; 41 | 42 | // Gradient colors for the progress arc 43 | const PROGRESS_GRADIENT_COLORS = ['#F04EA2', '#CD1D5E', '#D6215A', '#F13758']; 44 | const PROGRESS_GRADIENT_POSITIONS = [0.5, 0.75, 0.85, 1.0]; 45 | 46 | // Define interface for Dot component props 47 | interface DotProps { 48 | x: number; 49 | y: number; 50 | size: number; 51 | } 52 | 53 | // Memoized component for rendering a single dot 54 | const Dot = memo(({ x, y, size }: DotProps) => { 55 | return ; 56 | }); 57 | 58 | // Define interface for Knob component props 59 | interface KnobProps { 60 | knobX: any; // Using 'any' for Skia's useDerivedValue type 61 | knobY: any; 62 | isPressed: any; 63 | knobSize: number; 64 | } 65 | 66 | // Memoized component for rendering the knob 67 | const Knob = memo(({ knobX, knobY, isPressed, knobSize }: KnobProps) => { 68 | return ( 69 | [ 71 | { translateX: knobX.value }, 72 | { translateY: knobY.value }, 73 | { scale: isPressed.value ? 1.05 : 1 }, 74 | ])} 75 | > 76 | {/* Gradient Border Circle */} 77 | 78 | 83 | 84 | 85 | {/* Inner Filled Circle */} 86 | 87 | 92 | 93 | 94 | ); 95 | }); 96 | 97 | // Define interface for ValueDisplay component props 98 | interface ValueDisplayProps { 99 | value: string; 100 | } 101 | 102 | // Memoized component for displaying the value 103 | const ValueDisplay = memo(({ value }: ValueDisplayProps) => { 104 | return ( 105 | 106 | {value} 107 | months 108 | 109 | ); 110 | }); 111 | 112 | /** 113 | * Airbnb-style circular slider component for selecting months 114 | */ 115 | export default function HomeScreen() { 116 | // State for the display value (1-12 months) 117 | const [displayText, setDisplayText] = useState('1'); 118 | 119 | // Reanimated values for gestures and animations 120 | const progressReanimated = useSharedValue(0.1); // Initial value (represents ~1 month) 121 | const isPressed = useSharedValue(false); 122 | const screen = useWindowDimensions(); 123 | 124 | // Reference to the canvas container view for measuring position 125 | const canvasRef = useRef(null); 126 | 127 | // Memoize the setDisplayText callback to prevent unnecessary re-renders 128 | const setDisplayTextCallback = useCallback((value: string) => { 129 | setDisplayText(value); 130 | }, []); 131 | 132 | // Calculate the display value (snapped to nearest month) 133 | const displayValue = useDerivedValue(() => { 134 | const months = Math.round(progressReanimated.value * MAX_MONTHS); 135 | return months.toString(); 136 | }); 137 | 138 | // Update the React state when the animated value changes 139 | useDerivedValue(() => { 140 | runOnJS(setDisplayTextCallback)(displayValue.value); 141 | }); 142 | 143 | // Create the arc path for the progress indicator 144 | const arcPath = usePathValue((path) => { 145 | 'worklet'; 146 | path.reset(); 147 | 148 | const progress = progressReanimated.value; 149 | const startAngle = -Math.PI / 2 / 1.06; 150 | const endAngle = -Math.PI / 2 + 2 * Math.PI * progress; 151 | 152 | // Calculate the main arc points 153 | const arcRadius = CIRCLE_RADIUS; 154 | 155 | // Start point (flat cap) 156 | const startX = CENTER + Math.cos(startAngle) * arcRadius; 157 | const startY = CENTER + Math.sin(startAngle) * arcRadius; 158 | 159 | // Draw the arc 160 | path.moveTo(startX, startY); 161 | 162 | // Draw the arc using small line segments for smooth appearance 163 | const steps = 64; 164 | for (let i = 0; i <= steps; i++) { 165 | const angle = startAngle + (endAngle - startAngle) * (i / steps); 166 | const x = CENTER + Math.cos(angle) * arcRadius; 167 | const y = CENTER + Math.sin(angle) * arcRadius; 168 | path.lineTo(x, y); 169 | } 170 | 171 | return path; 172 | }); 173 | 174 | // Calculate the position of the knob 175 | const knobX = useDerivedValue(() => { 176 | const progress = progressReanimated.value; 177 | const angle = -Math.PI / 2 + 2 * Math.PI * progress; 178 | return CENTER + Math.cos(angle) * CIRCLE_RADIUS; 179 | }); 180 | 181 | const knobY = useDerivedValue(() => { 182 | const progress = progressReanimated.value; 183 | const angle = -Math.PI / 2 + 2 * Math.PI * progress; 184 | return CENTER + Math.sin(angle) * CIRCLE_RADIUS; 185 | }); 186 | 187 | // Create dots around the circle - memoized to prevent recreation on each render 188 | const dots = useMemo(() => { 189 | return Array.from({ length: MAX_MONTHS }).map((_, index) => { 190 | const angle = -Math.PI / 2 + (2 * Math.PI * index) / MAX_MONTHS; 191 | const x = CENTER + Math.cos(angle) * CIRCLE_RADIUS; 192 | const y = CENTER + Math.sin(angle) * CIRCLE_RADIUS; 193 | return { x, y, index }; 194 | }); 195 | }, []); 196 | 197 | // Handle pan gestures for the slider - memoized to prevent recreation on each render 198 | const gesture = useMemo(() => { 199 | return Gesture.Pan() 200 | .onBegin(() => { 201 | isPressed.value = true; 202 | }) 203 | .onUpdate((e) => { 204 | if (!canvasRef.current) return; 205 | 206 | // Calculate the offset applied by the transform 207 | const offsetX = screen.width / 2 - CIRCLE_SIZE / 2; 208 | const offsetY = screen.height / 2 - CIRCLE_SIZE / 2; 209 | 210 | // Get the touch position relative to the canvas center 211 | const touchX = e.x - (CENTER + offsetX); 212 | const touchY = e.y - (CENTER + offsetY); 213 | 214 | // Calculate the angle in radians 215 | let angle = Math.atan2(touchY, touchX); 216 | 217 | // Normalize angle to start from the top (12 o'clock position) 218 | angle = (angle + Math.PI * 2.5) % (Math.PI * 2); 219 | 220 | // Convert angle to progress (0-1) - stepless during dragging 221 | let newProgress = angle / (2 * Math.PI); 222 | 223 | // Ensure the progress is within bounds 224 | newProgress = Math.max(1 / MAX_MONTHS, Math.min(1, newProgress)); 225 | 226 | // Update progress - no snapping during drag 227 | progressReanimated.value = newProgress; 228 | }) 229 | .onEnd(() => { 230 | // Snap to nearest month when finger is lifted 231 | const nearestMonth = 232 | Math.round(progressReanimated.value * MAX_MONTHS) / MAX_MONTHS; 233 | progressReanimated.value = withTiming(nearestMonth, { 234 | duration: 100, 235 | }); 236 | isPressed.value = false; 237 | }); 238 | }, [screen.width, screen.height]); 239 | 240 | // Path for the rounded cap at the start position 241 | const startCapPath = usePathValue((path) => { 242 | 'worklet'; 243 | path.reset(); 244 | 245 | // Calculate dimensions for the rounded rectangle 246 | const capWidth = INNER_CIRCLE_STROKE_WIDTH / 4; 247 | const capHeight = INNER_CIRCLE_STROKE_WIDTH * 1.0003; 248 | const cornerRadius = INNER_CIRCLE_STROKE_WIDTH / 8; 249 | 250 | // Position the cap at the start of the arc (top of circle) 251 | const capX = CENTER - capWidth / 10; 252 | const capY = (CENTER - CIRCLE_RADIUS - capHeight / 2) * 1.15; 253 | 254 | // Draw rounded rectangle with adjusted top-right corner 255 | path.moveTo(capX + cornerRadius, capY); 256 | // Top edge (stops before the corner) 257 | path.lineTo(capX + capWidth - cornerRadius, capY); 258 | // Top-right corner (pushed down) 259 | path.quadTo(capX + capWidth, capY, capX + capWidth + 2, capY + 0.1); 260 | // Right edge 261 | path.lineTo(capX + capWidth, capY + capHeight); 262 | // Bottom-right corner 263 | path.quadTo( 264 | capX + capWidth, 265 | capY + capHeight, 266 | capX + capWidth - cornerRadius, 267 | capY + capHeight 268 | ); 269 | // Bottom edge 270 | path.lineTo(capX + cornerRadius, capY + capHeight); 271 | // Bottom-left corner 272 | path.quadTo(capX, capY + capHeight, capX, capY + capHeight - cornerRadius); 273 | // Left edge 274 | path.lineTo(capX, capY + cornerRadius); 275 | // Top-left corner 276 | path.quadTo(capX, capY, capX + cornerRadius, capY); 277 | 278 | path.close(); 279 | return path; 280 | }); 281 | 282 | // Memoize the transform values for the main Group component 283 | const mainGroupTransform = useMemo(() => { 284 | return [ 285 | { translateX: screen.width / 2 - CIRCLE_SIZE / 2 }, 286 | { translateY: screen.height / 2 - CIRCLE_SIZE / 2 }, 287 | ]; 288 | }, [screen.width, screen.height]); 289 | 290 | return ( 291 | 292 | 293 | 294 | 295 | 296 | {/* Background circle with shadows */} 297 | 305 | 306 | 307 | 308 | 309 | 310 | {/* Month indicator dots around the circle */} 311 | 312 | {dots.map((dot) => ( 313 | 314 | ))} 315 | 316 | 317 | {/* Inner circle with shadows */} 318 | 324 | 325 | 326 | 327 | 328 | {/* Glow effect for the knob */} 329 | 330 | 339 | } 340 | > 341 | 342 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | {/* Glow effect for the start cap */} 356 | 357 | 366 | } 367 | > 368 | 369 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | {/* Progress arc with shadows */} 383 | 386 | 387 | 388 | 389 | 390 | } 391 | > 392 | 395 | 396 | 397 | 398 | } 399 | > 400 | {/* Main progress arc */} 401 | 408 | 414 | 415 | 416 | {/* Rounded cap at the start position */} 417 | 418 | 424 | 425 | 426 | {/* End cap circle */} 427 | 434 | 440 | 441 | 442 | 443 | 444 | {/* White knob at the end of the progress */} 445 | 451 | 452 | 453 | 454 | 455 | 456 | {/* Display the current month value */} 457 | 458 | 459 | ); 460 | } 461 | 462 | const styles = StyleSheet.create({ 463 | container: { 464 | flex: 1, 465 | justifyContent: 'center', 466 | alignItems: 'center', 467 | backgroundColor: '#F7F7F7', 468 | }, 469 | canvasContainer: { 470 | width: '100%', 471 | height: '100%', 472 | }, 473 | canvas: { 474 | width: '100%', 475 | height: '100%', 476 | }, 477 | valueContainer: { 478 | position: 'absolute', 479 | alignItems: 'center', 480 | }, 481 | valueText: { 482 | fontFamily: 'AirbnbCereal', 483 | fontSize: 130, 484 | fontWeight: 'bold', 485 | color: '#222222', 486 | marginTop: -30, 487 | }, 488 | unitText: { 489 | fontFamily: 'AirbnbCereal', 490 | fontSize: 24, 491 | fontWeight: 'bold', 492 | color: '#222222', 493 | marginTop: -25, 494 | }, 495 | }); 496 | -------------------------------------------------------------------------------- /assets/fonts/AirbnbCereal_Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/fonts/AirbnbCereal_Bold.otf -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lauridskern/react-native-airbnb-slider/e598d2800d73400294a143e253641542874ad12d/assets/images/splash-icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airbnb-slider", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint" 13 | }, 14 | "jest": { 15 | "preset": "jest-expo" 16 | }, 17 | "dependencies": { 18 | "@expo/vector-icons": "^14.0.4", 19 | "@react-navigation/bottom-tabs": "^7.2.1", 20 | "@react-navigation/native": "^7.0.15", 21 | "@shopify/react-native-skia": "1.5.0", 22 | "expo": "~52.0.37", 23 | "expo-blur": "~14.0.3", 24 | "expo-constants": "~17.0.7", 25 | "expo-font": "~13.0.4", 26 | "expo-haptics": "~14.0.1", 27 | "expo-linking": "~7.0.5", 28 | "expo-router": "~4.0.17", 29 | "expo-splash-screen": "~0.29.22", 30 | "expo-status-bar": "~2.0.1", 31 | "expo-symbols": "~0.2.2", 32 | "expo-system-ui": "~4.0.8", 33 | "expo-web-browser": "~14.0.2", 34 | "react": "18.3.1", 35 | "react-dom": "18.3.1", 36 | "react-native": "0.76.7", 37 | "react-native-gesture-handler": "^2.24.0", 38 | "react-native-reanimated": "~3.16.7", 39 | "react-native-safe-area-context": "4.12.0", 40 | "react-native-screens": "~4.4.0", 41 | "react-native-web": "~0.19.13", 42 | "react-native-webview": "13.12.5" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.26.9", 46 | "@types/jest": "^29.5.14", 47 | "@types/react": "~18.3.18", 48 | "@types/react-test-renderer": "^18.3.1", 49 | "jest": "^29.7.0", 50 | "jest-expo": "~52.0.5", 51 | "react-test-renderer": "18.3.1", 52 | "typescript": "^5.8.2" 53 | }, 54 | "private": true 55 | } 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------