├── .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 | 
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 |
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 |
--------------------------------------------------------------------------------