├── .prettierignore ├── src ├── Label │ ├── index.ts │ └── Label.tsx ├── Submit │ ├── index.ts │ └── Submit.tsx ├── vite-env.d.ts ├── Input │ ├── index.ts │ ├── Text.tsx │ └── Input.tsx ├── Form │ ├── index.ts │ ├── FormContext.ts │ └── Form.tsx ├── Textarea │ ├── index.ts │ ├── Container.tsx │ ├── Text.tsx │ └── Textarea.tsx ├── index.ts ├── utils.ts ├── types.ts └── troika-three-text.d.ts ├── .storybook ├── assets │ └── fonts │ │ ├── fonts.d.ts │ │ ├── Poppins-Bold.ttf │ │ ├── Montserrat-Bold.ttf │ │ ├── MajorMonoDisplay.ttf │ │ └── PlayfairDisplay-Regular.ttf ├── manager-head.html ├── style.css ├── main.ts ├── preview.tsx ├── stories │ ├── Input.stories.tsx │ ├── Textarea.stories.tsx │ └── Form.stories.tsx └── common.tsx ├── .prettierrc ├── tsconfig.node.json ├── .gitignore ├── .github ├── workflows │ ├── build-ci.yml │ ├── semantic-release.yml │ └── build-storybook.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml └── pull_request_template.md ├── tsconfig.json ├── .eslintrc.cjs ├── vite.config.ts ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /src/Label/index.ts: -------------------------------------------------------------------------------- 1 | export { Label } from "./Label"; 2 | -------------------------------------------------------------------------------- /src/Submit/index.ts: -------------------------------------------------------------------------------- 1 | export { Submit } from "./Submit"; 2 | -------------------------------------------------------------------------------- /.storybook/assets/fonts/fonts.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.ttf"; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/Input/index.ts: -------------------------------------------------------------------------------- 1 | export { Input } from "./Input"; 2 | export { InputText } from "./Text"; 3 | -------------------------------------------------------------------------------- /src/Form/index.ts: -------------------------------------------------------------------------------- 1 | export { Form } from "./Form"; 2 | export { useFormContext } from "./FormContext"; 3 | -------------------------------------------------------------------------------- /src/Textarea/index.ts: -------------------------------------------------------------------------------- 1 | export { Textarea } from "./Textarea"; 2 | export { TextareaText } from "./Text"; 3 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /.storybook/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0 !important; 4 | } 5 | 6 | body { 7 | height: 100dvh; 8 | } 9 | -------------------------------------------------------------------------------- /.storybook/assets/fonts/Poppins-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMBeresford/r3f-form/HEAD/.storybook/assets/fonts/Poppins-Bold.ttf -------------------------------------------------------------------------------- /.storybook/assets/fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMBeresford/r3f-form/HEAD/.storybook/assets/fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /.storybook/assets/fonts/MajorMonoDisplay.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMBeresford/r3f-form/HEAD/.storybook/assets/fonts/MajorMonoDisplay.ttf -------------------------------------------------------------------------------- /.storybook/assets/fonts/PlayfairDisplay-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMBeresford/r3f-form/HEAD/.storybook/assets/fonts/PlayfairDisplay-Regular.ttf -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Input"; 2 | export * from "./Label"; 3 | export * from "./Textarea"; 4 | export * from "./Form"; 5 | export * from "./Submit"; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "useTabs": false 8 | } 9 | -------------------------------------------------------------------------------- /src/Form/FormContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const FormContext = React.createContext>({ current: null }); 4 | 5 | export function useFormContext() { 6 | return React.useContext(FormContext); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function lerp(x: number, y: number, t: number): number { 2 | return x + (y - x) * t; 3 | } 4 | 5 | export function damp(x: number, y: number, lambda: number, dt: number): number { 6 | return lerp(x, y, 1 - Math.exp(-lambda * dt)); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # storybook artifacts 27 | storybook-static 28 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: [ 5 | "../src/**/*.mdx", 6 | "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)", 7 | "./stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", 8 | ], 9 | framework: { 10 | name: "@storybook/react-vite", 11 | options: {}, 12 | }, 13 | staticDirs: ["./assets"], 14 | docs: { 15 | autodocs: "tag", 16 | }, 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /.github/workflows/build-ci.yml: -------------------------------------------------------------------------------- 1 | name: Build CI 2 | 3 | on: 4 | pull_request: 5 | types: ["opened", "reopened", "synchronize"] 6 | branches: ["main"] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: "18.x" 16 | - name: Setup 17 | run: npm ci 18 | - name: Format 19 | run: npm run format:check 20 | - name: Lint 21 | run: npm run lint 22 | - name: Build 23 | run: npm run build 24 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import "./style.css"; 3 | import type { Preview } from "@storybook/react"; 4 | import { Scene } from "./common"; 5 | 6 | const preview: Preview = { 7 | decorators: [ 8 | (Story) => ( 9 | 10 |
11 | 12 | 13 | 14 |
15 |
16 | ), 17 | ], 18 | parameters: { 19 | actions: { argTypesRegex: "^on[A-Z].*" }, 20 | }, 21 | }; 22 | 23 | export default preview; 24 | -------------------------------------------------------------------------------- /.github/workflows/semantic-release.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Release 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | workflow_dispatch: 8 | # allows manual trigger in github web UI 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "20.11.1" 18 | - run: npm ci 19 | - run: npm run build 20 | - name: Release 21 | env: 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} 24 | run: npm run semantic-release 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "feat: " 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noImplicitAny": true 19 | }, 20 | "include": ["src", ".storybook"], 21 | "references": [ 22 | { 23 | "path": "./tsconfig.node.json" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Color } from "@react-three/fiber"; 2 | import { TextRenderInfo } from "troika-three-text"; 3 | 4 | export type TroikaTextProps = { 5 | color?: Color; 6 | fontSize?: number; 7 | letterSpacing?: number; 8 | font?: string; 9 | depthOffset?: number; 10 | outlineWidth?: number | string; 11 | outlineOffsetX?: number | string; 12 | outlineOffsetY?: number | string; 13 | outlineBlur?: number | string; 14 | outlineColor?: Color; 15 | outlineOpacity?: number; 16 | strokeWidth?: number | string; 17 | strokeColor?: Color; 18 | strokeOpacity?: number; 19 | fillOpacity?: number; 20 | fillColor?: Color; 21 | onSync?: (troika: { textRenderInfo: TextRenderInfo }) => void; 22 | } & Omit; 23 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | "plugin:react/recommended", 9 | "plugin:storybook/recommended", 10 | ], 11 | ignorePatterns: ["dist", ".eslintrc.cjs"], 12 | parser: "@typescript-eslint/parser", 13 | plugins: ["react-refresh", "prettier"], 14 | rules: { 15 | "react/no-unknown-property": "off", 16 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], 17 | "react/jsx-uses-react": "off", 18 | "react/react-in-jsx-scope": "off", 19 | }, 20 | settings: { 21 | react: { 22 | version: "detect", 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | groups: 13 | major-updates: 14 | update-types: 15 | - "major" 16 | ignore: 17 | - dependency-name: "*" 18 | update-types: ["version-update:semver-minor", "version-update:semver-patch"] 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import dts from "vite-plugin-dts"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | build: { 8 | lib: { 9 | entry: "src/index.ts", 10 | name: "r3f-form", 11 | fileName: "index", 12 | formats: ["es"], 13 | }, 14 | rollupOptions: { 15 | external: [ 16 | "react", 17 | "@react-three/fiber", 18 | "@react-three/drei", 19 | "troika-three-text", 20 | "react-dom", 21 | "three", 22 | "react/jsx-runtime", 23 | ], 24 | output: { 25 | globals: { 26 | react: "React", 27 | }, 28 | }, 29 | }, 30 | }, 31 | plugins: [dts({ exclude: "src/utils.ts" }), react()], 32 | }); 33 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - **Please check if the PR fulfills these requirements** 2 | 3 | * [ ] The commit message follows our guidelines: https://www.conventionalcommits.org/ 4 | * [ ] Docs and READMEs have been added / updated (if required) 5 | * [ ] Storybook(s) have been added / updated (if required) 6 | 7 | - **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 8 | e.g. fixes #, resolves # with additional descriptions if needed 9 | 10 | - **What is the current behavior?** (You can also link to an open issue here) 11 | 12 | - **What is the new behavior (if this is a feature change)?** 13 | 14 | - **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) 15 | 16 | - **Other information**: 17 | -------------------------------------------------------------------------------- /src/Textarea/Container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Color } from "@react-three/fiber"; 3 | import { useCursor } from "@react-three/drei"; 4 | 5 | type Props = { 6 | width: number; 7 | height: number; 8 | backgroundColor: Color; 9 | backgroundOpacity: number; 10 | } & JSX.IntrinsicElements["mesh"]; 11 | 12 | const Container = (props: Props) => { 13 | const { width, height, backgroundColor, backgroundOpacity, ...restProps } = props; 14 | 15 | const [hovered, setHovered] = React.useState(false); 16 | useCursor(hovered, "text"); 17 | 18 | return ( 19 | setHovered(true)} 21 | onPointerLeave={() => setHovered(false)} 22 | renderOrder={1} 23 | {...restProps} 24 | > 25 | 26 | 32 | 33 | ); 34 | }; 35 | 36 | export default Container; 37 | -------------------------------------------------------------------------------- /.github/workflows/build-storybook.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy Storybook to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | buildAndDeploy: 25 | environment: 26 | name: github-pages 27 | url: ${{ steps.deployment.outputs.page_url }} 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: actions/setup-node@v3 32 | with: 33 | node-version: "16.x" 34 | registry-url: https://registry.npmjs.org/ 35 | - run: npm ci 36 | - run: npm run build-storybook 37 | - name: Setup Pages 38 | uses: actions/configure-pages@v2 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v1 41 | with: 42 | # Upload only storybook static files 43 | path: "storybook-static" 44 | - name: Deploy to GitHub Pages 45 | id: deployment 46 | uses: actions/deploy-pages@v1 47 | -------------------------------------------------------------------------------- /src/Label/Label.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from "@react-three/fiber"; 2 | import { Text } from "@react-three/drei"; 3 | 4 | type Props = { 5 | characters?: string; 6 | color?: Color; 7 | fontSize?: number; 8 | maxWidth?: number; 9 | lineHeight?: number; 10 | letterSpacing?: number; 11 | textAlign?: "left" | "right" | "center" | "justify"; 12 | font?: string; 13 | anchorX?: number | "left" | "center" | "right"; 14 | anchorY?: number | "top" | "top-baseline" | "middle" | "bottom-baseline" | "bottom"; 15 | depthOffset?: number; 16 | overflowWrap?: "normal" | "break-word"; 17 | whiteSpace?: "normal" | "overflowWrap" | "nowrap"; 18 | outlineWidth?: number | string; 19 | outlineOffsetX?: number | string; 20 | outlineOffsetY?: number | string; 21 | outlineBlur?: number | string; 22 | outlineOpacity?: number; 23 | strokeWidth?: number | string; 24 | strokeOpacity?: number; 25 | fillOpacity?: number; 26 | debugSDF?: boolean; 27 | text?: string; 28 | } & JSX.IntrinsicElements["mesh"]; 29 | 30 | const Label = (props: Props) => { 31 | const { 32 | color = "black", 33 | text, 34 | anchorX = "center", 35 | anchorY = "bottom", 36 | fontSize = 0.07, 37 | ...restProps 38 | } = props; 39 | 40 | return ( 41 | <> 42 | 43 | 44 | {text} 45 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export { Label }; 53 | -------------------------------------------------------------------------------- /src/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | import { useThree } from "@react-three/fiber"; 2 | import * as React from "react"; 3 | import * as ReactDOM from "react-dom/client"; 4 | import { FormContext } from "./FormContext"; 5 | 6 | type Props = Omit, "ref">; 7 | 8 | const Form = React.forwardRef((props: Props, ref: React.ForwardedRef) => { 9 | const { children, ...restProps } = props; 10 | const events = useThree((s) => s.events); 11 | const gl = useThree((s) => s.gl); 12 | const root = React.useRef(null); 13 | const domRef = React.useRef(null!); 14 | const target = (events.connected || gl.domElement.parentNode) as HTMLElement; 15 | React.useImperativeHandle(ref, () => domRef.current); 16 | 17 | const [domEl] = React.useState(() => document.createElement("div")); 18 | 19 | React.useLayoutEffect(() => { 20 | root.current = ReactDOM.createRoot(domEl); 21 | const curRoot = root.current; 22 | 23 | target?.appendChild(domEl); 24 | 25 | return () => { 26 | target?.removeChild(domEl); 27 | curRoot.unmount(); 28 | }; 29 | }, [target, domEl]); 30 | 31 | React.useLayoutEffect(() => { 32 | root.current?.render( 33 |
39 | ); 40 | }); 41 | 42 | return {children}; 43 | }); 44 | 45 | Form.displayName = "Form"; 46 | 47 | export { Form }; 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "bug: " 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Code Sandbox** 24 | Sometimes it is easier to show rather than tell. Try to reproduce the bug in a codesandbox and give us the link. You can use this template to start: 25 | 26 | https://codesandbox.io/p/sandbox/r3f-form-bug-report-5lwyrr 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Desktop (please complete the following information):** 32 | 33 | - OS: [e.g. macOS, windows] 34 | - OS Version [e.g. 13.3] 35 | - Browser [e.g. chrome, safari] 36 | - Browser Version [e.g. 22.5] 37 | 38 | **Smartphone (please complete the following information):** 39 | 40 | - Device: [e.g. iPhone6] 41 | - OS and version: [e.g. iOS8.1] 42 | - Browser [e.g. stock browser, safari] 43 | - Browser Version [e.g. 22] 44 | 45 | **Package Versions** 46 | 47 | - Node.js version [e.g. 16.x] 48 | - NPM version [e.g. 8.9.x] 49 | - React Three Fiber version [e.g. 9.x] 50 | - THREE.js version [e.g. 0.146.x] 51 | - Drei version [e.g. 8.x] 52 | - Troika-Three-Text version [e.g. 1.x] 53 | 54 | **Additional context** 55 | Add any other context about the problem here. 56 | -------------------------------------------------------------------------------- /src/Textarea/Text.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text as TextImpl, useMask } from "@react-three/drei"; 3 | import { Color } from "@react-three/fiber"; 4 | 5 | type Props = { 6 | characters?: string; 7 | color?: Color; 8 | fontSize?: number; 9 | maxWidth?: number; 10 | lineHeight?: number; 11 | letterSpacing?: number; 12 | textAlign?: "left" | "right" | "center" | "justify"; 13 | font?: string; 14 | anchorX?: number | "left" | "center" | "right"; 15 | anchorY?: number | "top" | "top-baseline" | "middle" | "bottom-baseline" | "bottom"; 16 | depthOffset?: number; 17 | outlineWidth?: number | string; 18 | outlineOffsetX?: number | string; 19 | outlineOffsetY?: number | string; 20 | outlineBlur?: number | string; 21 | outlineOpacity?: number; 22 | strokeWidth?: number | string; 23 | strokeOpacity?: number; 24 | fillOpacity?: number; 25 | debugSDF?: boolean; 26 | onSync?: (troika: unknown) => void; 27 | text?: string; 28 | } & JSX.IntrinsicElements["mesh"]; 29 | 30 | const TextareaText = React.forwardRef((props: Props, ref) => { 31 | const { 32 | fontSize = 0.07, 33 | anchorX = "left", 34 | anchorY = "middle", 35 | depthOffset = 0.5, 36 | color = "black", 37 | text, 38 | ...restProps 39 | } = props; 40 | 41 | const stencil = useMask(2); 42 | 43 | return ( 44 | 52 | {text} 53 | 54 | 55 | ); 56 | }); 57 | 58 | TextareaText.displayName = "TextareaText"; 59 | 60 | export { TextareaText }; 61 | -------------------------------------------------------------------------------- /.storybook/stories/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Input, Label, InputText } from "../../src"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | component: Default, 7 | title: "Input", 8 | }; 9 | 10 | export default meta; 11 | type Story = StoryObj; 12 | 13 | function Default() { 14 | return ; 15 | } 16 | 17 | function WithLabel() { 18 | return ( 19 | 20 | 23 | ); 24 | } 25 | 26 | function WithPlaceholder() { 27 | return ; 28 | } 29 | 30 | function WithColor() { 31 | return ( 32 | 33 | 38 | ); 39 | } 40 | 41 | function WithCustomFont() { 42 | return ( 43 | 44 | 49 | ); 50 | } 51 | 52 | export const DefaultStory: Story = { 53 | name: "Default", 54 | render: () => , 55 | }; 56 | 57 | export const WithLabelStory: Story = { 58 | name: "With Label", 59 | render: () => , 60 | }; 61 | 62 | export const WithPlaceholderStory: Story = { 63 | name: "With Placeholder", 64 | render: () => , 65 | }; 66 | 67 | export const WithColorStory: Story = { 68 | name: "With Color", 69 | render: () => , 70 | }; 71 | 72 | export const WithCustomFontStory: Story = { 73 | name: "With Custom Font", 74 | render: () => , 75 | }; 76 | -------------------------------------------------------------------------------- /.storybook/stories/Textarea.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Textarea, Label, TextareaText } from "../../src"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | 5 | const meta: Meta = { 6 | component: Default, 7 | title: "Textarea", 8 | }; 9 | 10 | export default meta; 11 | type Story = StoryObj; 12 | 13 | function Default() { 14 | return 37 | 38 | ); 39 | } 40 | 41 | function WithCustomFont() { 42 | return ( 43 | 44 | 49 | ); 50 | } 51 | 52 | export const DefaultStory: Story = { 53 | name: "Default", 54 | render: () => , 55 | }; 56 | 57 | export const WithLabelStory: Story = { 58 | name: "With Label", 59 | render: () => , 60 | }; 61 | 62 | export const WithPlaceholderStory: Story = { 63 | name: "With Placeholder", 64 | render: () => , 65 | }; 66 | 67 | export const WithColorStory: Story = { 68 | name: "With Color", 69 | render: () => , 70 | }; 71 | 72 | export const WithCustomFontStory: Story = { 73 | name: "With Custom Font", 74 | render: () => , 75 | }; 76 | -------------------------------------------------------------------------------- /src/Input/Text.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text as TextImpl, useMask } from "@react-three/drei"; 3 | import { Color } from "@react-three/fiber"; 4 | import { TextRenderInfo } from "troika-three-text"; 5 | 6 | type Props = { 7 | characters?: string; 8 | color?: Color; 9 | fontSize?: number; 10 | maxWidth?: number; 11 | lineHeight?: number; 12 | letterSpacing?: number; 13 | textAlign?: "left" | "right" | "center" | "justify"; 14 | font?: string; 15 | anchorX?: number | "left" | "center" | "right"; 16 | anchorY?: number | "top" | "top-baseline" | "middle" | "bottom-baseline" | "bottom"; 17 | depthOffset?: number; 18 | overflowWrap?: "normal" | "break-word"; 19 | outlineWidth?: number | string; 20 | outlineOffsetX?: number | string; 21 | outlineOffsetY?: number | string; 22 | outlineBlur?: number | string; 23 | outlineOpacity?: number; 24 | strokeWidth?: number | string; 25 | strokeOpacity?: number; 26 | fillOpacity?: number; 27 | debugSDF?: boolean; 28 | onSync?: (troika: { textRenderInfo: TextRenderInfo }) => void; 29 | text?: string; 30 | } & JSX.IntrinsicElements["mesh"]; 31 | 32 | const InputText = React.forwardRef((props: Props, ref) => { 33 | const { 34 | fontSize = 0.07, 35 | anchorX = "left", 36 | anchorY = "middle", 37 | depthOffset = 0.5, 38 | color = "black", 39 | text, 40 | children, 41 | ...restProps 42 | } = props; 43 | 44 | const localRef = React.useRef(null); 45 | React.useImperativeHandle(ref, () => localRef.current); 46 | const stencil = useMask(1); 47 | 48 | return ( 49 | 58 | 59 | {children} 60 | {text} 61 | 62 | ); 63 | }); 64 | 65 | InputText.displayName = "InputText"; 66 | 67 | export { InputText }; 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0-development", 3 | "name": "r3f-form", 4 | "description": "An *accessible* webGL form component library for use with React Three Fiber", 5 | "type": "module", 6 | "module": "dist/index.js", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "scripts": { 13 | "dev": "vite", 14 | "build": "tsc && vite build", 15 | "format": "prettier --write .", 16 | "format:check": "prettier --check .", 17 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 18 | "preview": "vite preview", 19 | "commit": "cz", 20 | "semantic-release": "semantic-release --branches main", 21 | "storybook": "storybook dev -p 6006", 22 | "build-storybook": "storybook build" 23 | }, 24 | "author": "John Beresford", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "@storybook/addon-essentials": "^7.6.17", 28 | "@storybook/addon-interactions": "^7.6.17", 29 | "@storybook/addon-links": "^7.6.17", 30 | "@storybook/addon-onboarding": "^1.0.11", 31 | "@storybook/blocks": "^7.6.17", 32 | "@storybook/jest": "^0.2.3", 33 | "@storybook/react": "^7.6.17", 34 | "@storybook/react-vite": "^7.6.17", 35 | "@storybook/test": "^7.6.17", 36 | "@storybook/testing-library": "^0.2.2", 37 | "@types/react": "^18.2.56", 38 | "@types/react-dom": "^18.2.19", 39 | "@types/three": "^0.155.1", 40 | "@typescript-eslint/eslint-plugin": "^7.0.2", 41 | "@typescript-eslint/parser": "^7.0.2", 42 | "@vitejs/plugin-react": "^4.2.1", 43 | "commitizen": "^4.3.0", 44 | "cz-conventional-changelog": "^3.3.0", 45 | "eslint": "^8.56.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "eslint-plugin-prettier": "^5.0.0", 48 | "eslint-plugin-react": "^7.31.11", 49 | "eslint-plugin-react-hooks": "^4.6.0", 50 | "eslint-plugin-react-refresh": "^0.4.5", 51 | "eslint-plugin-storybook": "^0.8.0", 52 | "prettier": "^3.0.0", 53 | "semantic-release": "^23.0.2", 54 | "storybook": "^7.6.17", 55 | "tslib": "^2.6.2", 56 | "typescript": "^5.2.2", 57 | "vite": "^5.1.4", 58 | "vite-plugin-dts": "^3.7.3" 59 | }, 60 | "peerDependencies": { 61 | "@react-three/drei": ">=9.0", 62 | "@react-three/fiber": ">=8.0", 63 | "react": ">=18.0", 64 | "troika-three-text": "^0.49.0", 65 | "react-dom": ">=18.0", 66 | "three": ">=0.146.0" 67 | }, 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/JMBeresford/r3f-form.git" 71 | }, 72 | "config": { 73 | "commitizen": { 74 | "path": "./node_modules/cz-conventional-changelog" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.storybook/common.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Canvas, Color, useFrame, useThree } from "@react-three/fiber"; 3 | import { Environment, Float } from "@react-three/drei"; 4 | import { damp, randFloat, randInt, seededRandom } from "three/src/math/MathUtils"; 5 | import { Group } from "three"; 6 | 7 | const Shapes = () => { 8 | let n = 7; 9 | const ref = React.useRef(null); 10 | 11 | useFrame((_, delta) => { 12 | if (ref.current && ref.current.rotation.y) { 13 | ref.current.rotation.y += delta * 10; 14 | } 15 | }); 16 | 17 | return ( 18 | <> 19 | 20 | {Array.from({ length: n }).map((_, idx) => { 21 | const r = randFloat(0.35, 0.8); 22 | const b = randFloat(0.35, 0.8); 23 | const g = randFloat(0.35, 0.8); 24 | const color: Color = [r, g, b]; 25 | 26 | const pos: [number, number, number] = [ 27 | randFloat(0.2, 1.2) * (Math.random() < 0.5 ? 1 : -1), 28 | randFloat(0.2, 0.8) * (Math.random() < 0.5 ? 1 : -1), 29 | randFloat(0.2, 0.5) * (Math.random() < 0.5 ? 1 : -1), 30 | ]; 31 | 32 | const rot: [number, number, number] = [ 33 | randFloat(-Math.PI * 2, Math.PI * 2), 34 | randFloat(-Math.PI * 2, Math.PI * 2), 35 | randFloat(-Math.PI * 2, Math.PI * 2), 36 | ]; 37 | 38 | const geometries = [ 39 | , 40 | , 41 | , 42 | ]; 43 | 44 | return ( 45 | 46 | 47 | {geometries[randInt(0, geometries.length - 1)]} 48 | 49 | 50 | 51 | ); 52 | })} 53 | 54 | 55 | ); 56 | }; 57 | 58 | const Rig = () => { 59 | const camera = useThree((s) => s.camera); 60 | 61 | useFrame(({ mouse }, delta) => { 62 | let x = mouse.x * 0.1; 63 | let y = mouse.y * 0.1; 64 | 65 | camera.rotation.x = damp(camera.rotation.x, y, 12, delta); 66 | camera.rotation.y = damp(camera.rotation.y, -x, 12, delta); 67 | }); 68 | return <>; 69 | }; 70 | 71 | const Scene = ({ lightColor = "red", children }) => ( 72 | 73 | 74 | 75 | {/* */} 76 | 77 | 78 | {children} 79 | 80 | 81 | ); 82 | 83 | export { Scene }; 84 | -------------------------------------------------------------------------------- /src/Submit/Submit.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { Text, useCursor } from "@react-three/drei"; 4 | import { Color, ThreeEvent, useThree } from "@react-three/fiber"; 5 | import { useFormContext } from "../Form"; 6 | 7 | type ButtonProps = { 8 | value?: string; 9 | fontSize?: number; 10 | width?: number; 11 | height?: number; 12 | color?: Color; 13 | backgroundColor?: Color; 14 | }; 15 | 16 | export type Props = ButtonProps & 17 | Pick & 18 | Omit; 19 | 20 | const Button = (props: ButtonProps) => { 21 | const { 22 | backgroundColor, 23 | width = 1.5, 24 | height = 0.1325, 25 | fontSize = 0.0825, 26 | color = "black", 27 | value, 28 | } = props; 29 | 30 | return ( 31 | 32 | 33 | {value} 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | const Submit = React.forwardRef((props: Props, ref: React.ForwardedRef) => { 44 | const { value, children, position, rotation, scale, ...restProps } = props; 45 | 46 | const root = React.useRef(null); 47 | const formNode = useFormContext(); 48 | const events = useThree((s) => s.events); 49 | const gl = useThree((s) => s.gl); 50 | const target = (formNode?.current || events.connected || gl.domElement.parentNode) as HTMLElement; 51 | 52 | const [domEl] = React.useState(() => document.createElement("div")); 53 | const [hovered, setHovered] = React.useState(false); 54 | 55 | useCursor(hovered); 56 | 57 | const handleClick = React.useCallback( 58 | (e: ThreeEvent) => { 59 | e.stopPropagation(); 60 | 61 | if (formNode.current?.requestSubmit) { 62 | formNode.current.requestSubmit(); 63 | } else { 64 | formNode.current?.submit(); 65 | } 66 | }, 67 | [formNode] 68 | ); 69 | 70 | React.useLayoutEffect(() => { 71 | root.current = ReactDOM.createRoot(domEl); 72 | const curRoot = root.current; 73 | 74 | target?.appendChild(domEl); 75 | 76 | return () => { 77 | target?.removeChild(domEl); 78 | curRoot.unmount(); 79 | }; 80 | }, [domEl, target]); 81 | 82 | React.useLayoutEffect(() => { 83 | root.current?.render( 84 | 97 | ); 98 | }); 99 | 100 | return ( 101 | <> 102 | setHovered(true)} 105 | onPointerLeave={() => setHovered(false)} 106 | position={position} 107 | rotation={rotation} 108 | scale={scale} 109 | > 110 | {children ? children :