16
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

StyleXおさわり

16
Last updated at Posted at 2024-03-28

本記事の目的

2023年12月5日、ReactをつくったMeta社がスタイリングライブラリ「StyleX」の正式版を公開した。
フロントエンド業務経験のないエンジニアにもわかるように、
StyleXについて調べて得た知見を共有することを本記事の目的とした:

具体的には、以下の3️点について共有する:

  • かんたんなStyleXの導入方法
  • StyleXの使い方・コード例
  • StyleXを使用した感想

類似記事

用語解説

CSS-in-JS

  • Webページの見た目に関するスタイルを、従来のCSSファイルの代わりに、JavaScript内で定義し適用する手法
  • CSSではページ全体に適用するのに対し、CSS-in-JSではスタイルをコンポーネントに適用する
  • スタイルの衝突や名前空間汚染のリスクが低減され、プロジェクトの管理が容易になる

StyleXの紹介

高速性について

  • コンパイル時に静的なCSSファイルへバンドル
    • すべてのスタイルを1つのCSSファイルにまとめ上げる
      • ブラウザがページを読み込む際に追加のスタイル計算が不要になり、読み込み時間が短縮される
  • 実行時にクラス名の組み合わせを最適化
    • 生成された静的CSSファイルが必要なスタイルのみを含むように最適化されている
      • 実行時のパフォーマンスが向上する

スケーラビリティに対するアプローチ

  • Atomic CSSの採用
    • Atomic CSSの原則に従い、スタイルを最小の単位に分解する
      • 重複するスタイルの削減とCSS全体のサイズの縮小を実現する
      • 大規模なプロジェクトでも管理しやすくなる
  • コードベースの成長に対応
    • プロジェクトの規模が拡大しても、スタイルの読みやすさと保守のしやすさを維持できる

予測可能性の確保

  • 要素固有のスタイル適用
    • クラス名を使って特定の要素に直接スタイルを適用する
      • 意図しないスタイルの衝突を防ぎ、CSSの特異性による問題を解消できる
  • スタイルの優先順位が明確
    • 「最後に適用されたスタイルが勝つ」という原則がある
      • スタイルの適用順序が直感的に理解でき、予測可能になる

組み合わせやすさの向上

  • 条件付きスタイルの適用
    • 状況に応じてスタイルを動的に変更することができる
      • 同じコンポーネントでも異なるスタイルを適用できるようになる
  • スタイルの再利用
    • ローカル定数や式を使用してスタイルを定義できる
      • スタイルの重複を避けつつ、必要に応じて再利用できるようになる
    • パフォーマンスの懸念なくスタイルを繰り返し使用することができる

その他

  • 型安全性
    • TypeScriptやFlowを利用することで、スタイルプロパティの型安全性を確保できる
      • 予期せぬスタイルの適用ミスを防ぐことができる
  • コロケーションの奨励
    • スタイル定義をコンポーネントと同じファイル内に記述することを奨励している
      • スタイルの可読性と保守性を向上させる
  • テスト可能性の強化
    • デバッグクラス名を設定し、出力することができる
      • スタイルの変更のスナップショットテストに与える影響が最小限となる

導入

StyleXをインストール

下記のコマンドを実行:

npm install --save @stylexjs/stylex

❓️ --save--save-dev はなにがちがうのか

  • --save
    • npm5 からデフォルトで適用
    • dependenciesに追加
  • --save-dev - 開発に必要なものを記載 - devDependenciesに追加
    ::

https://k-koh.hatenablog.com/entry/2020/04/07/165203

[Vite] プラグインをインストール

https://www.npmjs.com/package/vite-plugin-stylex

https://github.com/HorusGoul/vite-plugin-stylex

詳細

下記のコマンドを実行:

npm i vite-plugin-stylex

下記のように設定ファイルを更新:

vite.config.ts
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
+ import styleX from 'vite-plugin-stylex';

// https://vitejs.dev/config/
export default defineConfig({
-   plugins: [react()],
+   plugins: [react(), styleX()],
    base: '/react_ui_comparison',
});

https://qiita.com/xrxoxcxox/items/8605a7a92610db56011e

[VSCODE] 拡張機能をインストール

https://marketplace.visualstudio.com/items?itemName=yash-singh.stylex

その他

公式が、各種プラグインを紹介したり、各種フレームワークでの始めるためのテンプレートを共有したりしてくれている。

https://stylexjs.com/docs/learn/ecosystem/

使い方

公式コード例

https://stylexjs.com/docs/learn/styling-ui/using-styles/

https://stylexjs.com/docs/learn/styling-ui/defining-styles/

https://stylexjs.com/docs/api/javascript/keyframes/

通常使用

import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
  base: {
    fontSize: 16,
    lineHeight: 1.5,
    color: "grey",
  },
  highlighted: {
    color: "rebeccapurple",
  },
});

<div {...stylex.props(styles.base, styles.highlighted)} />;
  • stylex.create にオブジェクトを入れてスタイルを生成
  • オブジェクトは 任意のキー: { 任意のスタイル名: スタイルに応じた値 }
  • stylex.props に生成したスタイルを渡して要素に入れて適用

疑似要素とメディアクエリ

import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
  button: {
    color: {
      default: "var(--blue-link)",
      ":hover": {
        default: null,
        "@media (hover: hover)": "scale(1.1)",
      },
      ":active": "scale(0.9)",
    },
  },
});
  • 疑似要素を適用する場合は、スタイルオブジェクト内で対象の疑似要素をキーとして指定
  • メディアクエリや条件付きスタイルを適用したい場合には、疑似要素と同様にキーとして指定
    • 組み合わせる場合は片方の値にオブジェクトとしてネストする
  • 特定の条件や疑似要素が適用されない場合のデフォルトのスタイルを定義するにはdefaultキーを指定

動的なスタイル

import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
  // Function arguments must be simple identifiers
  // -- No destructuring or default values
  bar: (height) => ({
    height,
    // The function body must be an object literal
    // -- { return {} } is not allowed
  }),
});

function MyComponent() {
  // The value of `height` cannot be known at compile time.
  const [height, setHeight] = useState(10);

  return <div {...stylex.props(styles.bar(height))} />;
}
  • 動的なスタイルを定義する場合は、関数を使用する

keyframe

import * as stylex from "@stylexjs/stylex";

const pulse = stylex.keyframes({
  "0%": { transform: "scale(1)" },
  "50%": { transform: "scale(1.1)" },
  "100%": { transform: "scale(1)" },
});

const styles = stylex.create({
  pulse: {
    animationName: pulse,
    animationDuration: "1s",
    animationIterationCount: "infinite",
  },
});
  • stylex.keyframesでキーフレームを定義
  • stylex.createにキーフレームを渡す

私のコード

https://github.com/visionary-japan/react_ui_comparison/

:::note

  • ライブラリにReactを使用している
    ::

ボタン

https://visionary-japan.github.io/react_ui_comparison/

上のボタンが使用前で、下のボタンが使用後。
見た目・動作とも全く同じ。

仕様

  • ブラウザがダークモードなら黒色
  • ブラウザがライトモードなら灰色
  • ホバー時とフォーカス時に枠の色が変わる

使用前

スタイル定義部分(CSS)
Vite.css(抜粋)
.def-vite-button {
  width: 128px;
  height: 48px;
  font-family: inherit;
  font-size: 1em;
  font-weight: 500;
  color: #646cff;
  cursor: pointer;
  background-color: #1a1a1a;
  border: 1px solid transparent;
  border-radius: 8px;
  transition: border-color 0.25s;
}

.def-vite-button:hover {
  border-color: #646cff;
}

.def-vite-button:focus,
.def-vite-button:focus-visible {
  border-color: white;
}
コンポーネント使用部分(TSX)
Vite.tsx(抜粋)
<button
    type='button'
    className='def-vite-button'
    onClick={handleClickButton}
>
    count is {count}
</button>

使用後

スタイル定義部分(TSX)

Reactのbuttonオブジェクト要素ButtonHTMLAttributesを継承してみた。

Button.tsx (全文)
import * as stylex from '@stylexjs/stylex';
import type { ButtonHTMLAttributes, FC } from 'react';

interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
    size?: 'small' | 'medium' | 'large';
}

const styles = stylex.create({
    base: {
        fontFamily: 'inherit',
        color: '#646cff',
        cursor: 'pointer',
        backgroundColor: {
            default: '#1a1a1a',
            '@media (prefers-color-scheme: dark)': '#1a1a1a',
            '@media (prefers-color-scheme: light)': '#f9f9f9',
        },
        borderWidth: 1,
        borderStyle: 'solid',
        borderColor: {
            default: 'transparent',
            ':hover': '#646cff',
            ':focus': '#eee',
        },
        borderRadius: 8,
        transition: 'border-color 0.25s',
        willChange: 'border-color',
    },
});

const sizes = stylex.create({
    small: {
        width: 96,
        height: 32,
        fontSize: '0.8em',
        fontWeight: 300,
    },
    medium: {
        width: 128,
        height: 48,
        fontSize: '1em',
        fontWeight: 500,
    },
    large: {
        width: 160,
        height: 64,
        fontSize: '1.2em',
        fontWeight: 700,
    },
});

export const Button: FC<Props> = ({
    type = 'button',
    size = 'medium',
    children,
    ...props
}) => (
    <button type={type} {...stylex.props(styles.base, sizes[size])} {...props}>
        {children}
    </button>
);

コンポーネント使用箇所
Vite.tsx (抜粋)
<Button onClick={handleClickButton}>
    count is {count}
</Button>

相違点

  • 使用するコンポーネントがHTML標準buttonから自作Buttonに
  • typeプロパティがデフォルトで"button"なので省略可能に
  • StylesをStyleXに書いたのでclassNameを削除

ドロップエリア

https://visionary-japan.github.io/react_ui_comparison/dnd/pointer

仕様

  • 通常時は灰点線ボーダーの四角形
  • ドラッグ要素が重なることで、ボーダーの色が変化する

使用前

TSX
Drop.tsx
import { useEffect, useRef, useState } from 'react';
import {
    DragData,
    DropData,
    dropStyle,
} from '../../../pages/dnd/pointer/configs';
import './DropPointer.css';

interface Props {
    dragData: DragData | null;
    dropData: DropData;
}
export function Drop(props: Props) {
    const [isOver, setIsOver] = useState<boolean>(false);

    const ref = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        if (props.dragData && props.dropData) {
            if (!ref.current) return;
            setIsOver(
                props.dropData.isOver(
                    props.dragData,
                    ref.current.getBoundingClientRect(),
                ),
            );
        } else {
            setIsOver(false);
        }
    }, [props.dragData, props.dropData]);

    return (
        <div
            ref={ref}
            className={
                isOver
                    ? 'dnd-pointer-drop dnd-pointer-over'
                    : 'dnd-pointer-drop'
            }
            style={{
                ...dropStyle,
                borderColor: isOver ? props.dropData?.color : 'gray',
            }}
        />
    );
}

CSS

.dnd-pointer-drop {
  /* 枠 */
  border-style: dashed;

  /* 動的 */
  will-change: filter;
}

.dnd-pointer-over {
  /* 色 */
  filter: brightness(2.5);
}

使用後

諸事情でファイル名やコンポーネントの定義方法を更新している。

TSX
PointerDrop.tsx
import stylex from '@stylexjs/stylex';
import { type FC, memo, useEffect, useRef, useState } from 'react';
import type { DragData, DropData } from '../../pages/dnd/config';

interface Props {
    dragData: DragData | undefined;
    dropData: DropData | undefined;
}

export const DropWidth = 60;
export const DropHeight = 60;
export const DropBorderWidth = 2;

const styles = stylex.create({
    base: {
        width: DropWidth,
        height: DropHeight,
        borderWidth: DropBorderWidth,
        borderStyle: 'dashed',
        willChange: 'border-style',
        borderColor: 'gray',
    },
    over: (color: string | undefined) => ({
        filter: 'brightness(2.5)',
        borderColor: color,
    }),
});

const Drop: FC<Props> = props => {
    const [isOver, setIsOver] = useState<boolean>(false);

    const ref = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        if (props.dragData && props.dropData) {
            if (!ref.current) return;
            setIsOver(
                props.dropData.isOver(
                    props.dragData,
                    ref.current.getBoundingClientRect(),
                ),
            );
        } else {
            setIsOver(false);
        }
    }, [props.dragData, props.dropData]);

    return (
        <div
            ref={ref}
            {...stylex.props(
                styles.base,
                isOver && styles.over(props.dropData?.color),
            )}
        />
    );
};

export const PointerDrop = memo(Drop);

ドラッグ要素

https://visionary-japan.github.io/react_ui_comparison/dnd/pointer

仕様

  • 同じようにドラッグ可能
  • 見た目とドロップ対象の識別方法がそれぞれ異なる

使用前

TSX
import React, { useRef, useState } from "react";
import {
  DragData,
  DropData,
  location,
} from "../../../pages/dnd/pointer/configs";
import "./DragPointer.css";

interface Props {
  id: string;
  dragData: DragData;
  dropData: DropData;
  handleDragStart: (id: string) => void;
  handleDrag: (props: DragData) => void;
}
export function Drag(props: Props) {
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const [locOffset, setLocOffset] = useState<location>({ x: 0, y: 0 });
  const [locRectLast, setLocRectLast] = useState<location>({ x: 0, y: 0 });
  const [timeLast, setTimeLast] = useState<number>(0);

  const ref = useRef<HTMLDivElement>(null);

  const handlePointerDown = (event: React.PointerEvent) => {
    if (!ref.current) return;
    ref.current.classList.add("dnd-pointer-dragging");
    setIsDragging(true);
    event.currentTarget.setPointerCapture(event.pointerId);
    const rect = event.currentTarget.getBoundingClientRect();
    const x = event.clientX - rect.x;
    const y = event.clientY - rect.y;
    props.handleDrag({
      locScroll: { x: window.scrollX, y: window.scrollY },
      locClient: { x: event.clientX, y: event.clientY },
      locRect: rect,
      sizRect: rect,
    });
    setLocOffset({ x, y });
    setLocRectLast({ x, y });
    setTimeLast(Date.now());
    props.handleDragStart(props.id);
  };
  const handlePointerMove = (event: React.PointerEvent) => {
    if (!(isDragging && ref.current)) return;
    const x = event.clientX + window.scrollX - locOffset.x;
    const y = event.clientY + window.scrollY - locOffset.y;
    ref.current.style.left = `${x}px`;
    ref.current.style.top = `${y}px`;
    const rect = ref.current.getBoundingClientRect();
    const time = Date.now();
    const timeDelta = (time - timeLast) / 100;
    props.handleDrag({
      locScroll: { x: window.scrollX, y: window.scrollY },
      locClient: { x: event.clientX, y: event.clientY },
      locRect: {
        x,
        y,
      },
      sizRect: rect,
      locNext: {
        x: x + (x - locRectLast.x) / timeDelta,
        y: y + (y - locRectLast.y) / timeDelta,
      },
    });
    setLocRectLast({ x, y });
    setTimeLast(Date.now());
  };
  const handlePointerUp = () => {
    if (!(isDragging && ref.current)) return;
    setIsDragging(false);
    ref.current.classList.remove("dnd-pointer-dragging");
  };

  return (
    <div
      ref={ref}
      id={props.id}
      className="dnd-pointer-drag-wrap"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
    />
  );
}
CSS
/* 共通 */
.dnd-pointer-drag-wrap {
  position: absolute;
  touch-action: none;
  cursor: pointer;
  opacity: 0.75;
  will-change: left, top, filter, opacity;
}

.dnd-pointer-dragging {
  filter: drop-shadow(0 0 16px black);
  opacity: 0.9;
}

/* 固有 */

/* 01 */
#cursor {
  /* 位置 */
  top: 100px;
  left: 20%;

  /* サイズ */
  width: 96px;
  height: 96px;

  /* 色 */
  background-color: red;

  /* 枠 */
  border-radius: 16px;
}

/* 02 */
#center {
  /* 位置 */
  top: 200px;
  left: 40%;

  /* サイズ */
  width: 96px;
  height: 96px;

  /* 色 */
  background-color: green;

  /* 枠 */
  border-radius: 16px;
}

#center::before,
#center::after {
  position: absolute;
  content: "";
  background: black;
}

#center::before {
  top: 50%;
  right: 0;
  left: 0;
  height: 1px;
}

#center::after {
  top: 0;
  bottom: 0;
  left: 50%;
  width: 1px;
}

/* 03 */
#percent {
  /* 位置 */
  top: 300px;
  left: 60%;

  /* サイズ */
  width: 96px;
  height: 96px;
}

#percent::after {
  position: absolute;
  top: 0;
  left: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  content: "";
  background-color: blue;
}

/* 04 */

#hotspot {
  /* 位置 */
  top: 400px;
  left: 20%;

  /* サイズ */
  width: 96px;
  height: 96px;

  /* 画像 */
  background-image: url("/public/computer_cursor_arrow_white.png");
  background-repeat: no-repeat;
  background-position: top;
  background-size: contain;
}

#hotspot::after {
  position: absolute;
  top: 0;
  left: 50%;
  width: 2px;
  height: 2px;
  content: "";
  background-color: red;
  transform: translateX(-50%);
}

/* 05 */
#distance {
  /* 位置 */
  top: 500px;
  left: 40%;

  /* サイズ */
  width: 96px;
  height: 96px;

  /* 色 */
  background-color: yellow;

  /* 枠 */
  border-radius: 50%;
}

#distance.dragging {
  filter: drop-shadow(0 0 32px yellow);
}

/* 06 */
#velocity {
  /* 位置 */
  top: 600px;
  left: 60%;

  /* サイズ */
  width: 48px;
  height: 48px;

  /* 色 */
  background-color: purple;

  /* 枠 */
  border-radius: 50%;
}

使用後

index.d.ts
index.d.ts
import type { UserAuthoredStyles } from '@stylexjs/stylex';
import type { Location, Size } from '../../@types';

// ドラッグ情報
export interface DragData {
    locScroll?: Location;
    locClient?: Location;
    locRect?: Location;
    sizRect?: Size;
    locNext?: Location;
}

// 型
type StyleKeys = 'base' | 'dragging' | 'image';

// ドロップ情報
export interface DropData {
    color: string;
    isOver: (dragData: DragData, rectDrop: DOMRect) => boolean;
    styles: { [key in StyleKeys]: UserAuthoredStyles };
}

export type DndKeys =
    | 'cursor'
    | 'center'
    | 'percent'
    | 'hotspot'
    | 'distance'
    | 'velocity';

export type DropDatas = {
    [K in DndKeys]: DropData;
};
設定ファイル(一部)
01_cursor.ts
import * as stylex from '@stylexjs/stylex';
import type { DragData, DropData } from '.';

const styles = stylex.create({
    base: {
        backgroundColor: 'red',
        top: 100,
        left: '20%',
        borderRadius: 16,
    },
    dragging: {},
    image: {},
});

export const cursor: DropData = {
    color: 'red',
    isOver: (props: DragData, dropRect: DOMRect) => {
        const { locClient } = props;
        if (!locClient) return false;
        return (
            locClient.x >= dropRect.left &&
            locClient.x <= dropRect.right &&
            locClient.y >= dropRect.top &&
            locClient.y <= dropRect.bottom
        );
    },
    styles,
};
TSX
import stylex from "@stylexjs/stylex";
import type { FC, PointerEvent } from "react";
import { memo, useRef, useState } from "react";
import type { Location } from "../../@types";
import type { DragData, DropData } from "../../pages/dnd/config";

const styles = stylex.create({
  base: {
    position: "absolute",
    touchAction: "none",
    width: 96,
    height: 96,
    cursor: "pointer",
    opacity: 0.75,
    willChange: "left, top, filter, opacity",
  },
  dragging: {
    filter: "drop-shadow(0 0 16px black)",
    opacity: 0.9,
  },
});

interface Props {
  id: string;
  dragData: DragData;
  dropData: DropData;
  handleDragStart: (id: string) => void;
  handleDrag: (props: DragData) => void;
}

const Drag: FC<Props> = (props) => {
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const [locOffset, setLocOffset] = useState<Location>({ x: 0, y: 0 });
  const [locRectLast, setLocRectLast] = useState<Location>({ x: 0, y: 0 });
  const [timeLast, setTimeLast] = useState<number>(0);

  const ref = useRef<HTMLDivElement>(null);

  const handlePointerDown = (event: PointerEvent) => {
    if (!ref.current) return;
    setIsDragging(true);
    event.currentTarget.setPointerCapture(event.pointerId);
    const rect = event.currentTarget.getBoundingClientRect();
    const x = event.clientX - rect.x;
    const y = event.clientY - rect.y;
    props.handleDrag({
      locScroll: { x: window.scrollX, y: window.scrollY },
      locClient: { x: event.clientX, y: event.clientY },
      locRect: rect,
      sizRect: rect,
    });
    setLocOffset({ x, y });
    setLocRectLast({ x, y });
    setTimeLast(Date.now());
    props.handleDragStart(props.id);
  };
  const handlePointerMove = (event: PointerEvent) => {
    if (!(isDragging && ref.current)) return;
    const x = event.clientX + window.scrollX - locOffset.x;
    const y = event.clientY + window.scrollY - locOffset.y;
    ref.current.style.left = `${x}px`;
    ref.current.style.top = `${y}px`;
    const rect = ref.current.getBoundingClientRect();
    const time = Date.now();
    const timeDelta = (time - timeLast) / 100;
    props.handleDrag({
      locScroll: { x: window.scrollX, y: window.scrollY },
      locClient: { x: event.clientX, y: event.clientY },
      locRect: { x, y },
      sizRect: rect,
      locNext: {
        x: x + (x - locRectLast.x) / timeDelta,
        y: y + (y - locRectLast.y) / timeDelta,
      },
    });
    setLocRectLast({ x, y });
    setTimeLast(Date.now());
  };
  const handlePointerUp = () => {
    if (!(isDragging && ref.current)) return;
    setIsDragging(false);
  };

  return (
    <div
      ref={ref}
      className="dnd-pointer-drag-wrap"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      {...stylex.props(
        styles.base,
        isDragging && styles.dragging,
        props.dropData.styles.base,
        isDragging && props.dropData.styles.dragging,
        props.dropData.styles.image
      )}
    />
  );
};

export const PointerDrag = memo(Drag);

回転するロゴ(キーフレーム使用)

https://visionary-japan.github.io/react_ui_comparison/

仕様

  • 永遠に回転する
  • ホバー時に発光する

導入前

TSX
Vite.tsx (抜粋)
<div className='def-vite-logo-wrap'>
    <a
        href='https://vitejs.dev'
        target='_blank'
        rel='noreferrer'
        className='def-vite-a'
    >
        <div className='def-vite-dummy' />
        <img
            src={viteLogo}
            className='def-vite-logo'
            alt='Vite logo'
        />
    </a>
</div>
<div className='def-vite-logo-wrap'>
    <a
        href='https://react.dev'
        target='_blank'
        rel='noreferrer'
        className='def-vite-a'
    >
        <div className='dummy' />
        <img
            src={reactLogo}
            className='def-vite-logo def-vite-react'
            alt='React logo'
        />
    </a>
</div>
CSS
Vite.css (抜粋)
.def-vite-logo-wrap {
  position: relative;
}

.def-vite-a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;
}

.def-vite-a:hover {
  color: #535bf2;
}

.def-vite-dummy {
  position: absolute;
  width: 100%;
  height: 100%;
}

.def-vite-logo {
  height: 6em;
  padding: 1.5em;
  transition: filter 300ms;
  will-change: filter;
}

.def-vite-logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}

.def-vite-logo.react:hover {
  filter: drop-shadow(0 0 2em #61dafbaa);
}

@keyframes logo-spin {
  from {
    transform: rotate(0deg);
  }

  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .def-vite-logo {
    animation: logo-spin infinite 20s linear;
  }
}

導入後

TSX

Logo.tsx
import type { UserAuthoredStyles } from '@stylexjs/stylex';
import stylex from '@stylexjs/stylex';
import { type FC, memo } from 'react';

interface Props {
    url: string;
    src: string;
    alt: string;
    styles: { img: UserAuthoredStyles };
}

const spin = stylex.keyframes({
    from: { transform: 'rotate(0deg)' },
    to: { transform: 'rotate(360deg)' },
});

const styles = stylex.create({
    wrap: {
        position: 'relative',
    },
    a: {
        fontWeight: 500,
        color: {
            default: '#646cff',
            ':hover': '#535bf2',
        },
        textDecoration: 'inherit',
    },
    img: {
        height: '6em',
        padding: '1.5em',
        transition: 'filter 300ms',
        willChange: 'filter',
    },
    spin: {
        animationName: spin,
        animationDuration: '20s',
        animationIterationCount: 'infinite',
        animationTimingFunction: 'linear',
    },
});

const Component: FC<Props> = props => {
    return (
        <div {...stylex.props(styles.wrap)}>
            <a
                {...stylex.props(styles.a)}
                href={props.url}
                target='_blank'
                rel='noreferrer'
            >
                <img
                    {...stylex.props(styles.img, props.styles.img, styles.spin)}
                    src={props.src}
                    alt={props.alt}
                />
            </a>
        </div>
    );
};

export const Logo = memo(Component);

感想

前提として、従来方式とStyleXとの比較

従来: CSS

style.css
.button {
    background-color: red;
}
.button:hover {
    background-color: blue;
}

従来: styled-component

styled.ts
const style = styled.button`
    background-color: red;
    &:hover {
        background-color: blue;
    }
`

StyleX

stylex.ts
const styles = stylex.create({
    button: {
        backgroundColor: {
            default: 'red',
            ':hover': 'blue',
        },
    },
});

hoverやfocusを並列に置けて最高

これまでのCSS-in-JSライブラリでは、疑似要素をオブジェクトのルート階層に書くことが多かった。
そのため、スタイルが条件ごとにどのように変化するか確認するのが面倒だった。
StyleXではスタイルごとに条件を書けるようになり、見やすい構成となっている。

オブジェクトで書けて嬉しい

これまでのCSS-in-JSライブラリでは、スタイルを文字列で定義することが多かった。
そのため、スタイルを流用することが難しく、フォーマッターも機能しづらかった。
StyleXではオブジェクトで書けるようになり、TypeScriptで書くことに向いている。

CSSからの移植が少し面倒

styled-componentなどで文字列として記述できるのは、CSSからそのまま移植するためである。
オブジェクトのキーにはハイフンなどが使えない仕様から、StyleXではCSSをそのままコピー・アンド・ペーストすることはできない。
CSSから移植する場合には、スタイル名をオブジェクト用に変更したり、セミコロンをカンマに変えたりなど、他のCSS-in-JSと比べると少し手間がかかる。そこもかわいいけどね


2026年3月追記: あれから2年、StyleXはどうなったか

本記事は2023年12月のStyleX正式リリース直後に書いた「おさわり」記事です。
2年以上が経過したので、「結局StyleXは使うべきなのか」という視点で現状を補足します。

StyleXは順調に成長した

Meta社内ではFacebook・Instagram・WhatsApp・Messenger・Threadsの全製品に標準導入されています1
社外でもFigma・Snowflake・HubSpotが採用しました2
2025年にはView Transitions対応(stylex.viewTransitionClass)やAnchor Positioning対応(stylex.positionTry)など、モダンCSS機能への追従も進んでいます。

本記事で使用した vite-plugin-stylex(HorusGoul版)は開発が停滞していますが、2025年に公式の @stylexjs/unplugin がリリースされ、セットアップの手間は改善されました2

ただし、エコシステムは薄い

成長はしたものの、日常的に使えるエコシステムの厚みでは他の選択肢に及びません。

観点 StyleX Tailwind CSS CSS Modules
UIライブラリ連携 なし ※自前構築が前提 shadcn/ui, daisyUI, Headless UI等多数 MUI, Mantine等が標準対応
IDEサポート VS Code拡張あり(非公式) 公式IntelliSense拡張 標準のCSS補完が使える
学習リソース 公式ドキュメント中心 書籍・動画・Qiita記事が豊富 CSSの知識がそのまま使える
デバッグ 生成されたAtomic CSSクラス名は読みにくい クラス名が意味を持つため追いやすい 元のクラス名がスコープ付きで残る
既存CSSからの移行 オブジェクト記法への書き換えが必要(本記事の感想参照) クラス名の置き換えで段階的に移行可能 ファイルを .module.css にするだけ

たとえば「shadcn/ui + Tailwind」の組み合わせなら、UIコンポーネントからスタイリングまでを一貫した方法で扱えます。
StyleXにはこうした「すぐ使えるコンポーネント群」が存在しないため、デザインシステムを一から構築する覚悟が必要です。

Chakra UIコミュニティではStyleXへの移行が議論されましたが、結局Panda CSSが採用されました3
この事例は、StyleXの設計思想への関心はあっても、実際の移行コストの高さが障壁になることを示しています。

結局、どういうチームが選ぶべきか

StyleXが強いのは以下の条件が揃った場面です。

  • コンポーネント数が数百〜数千に及ぶ大規模アプリケーション
  • 複数チームが同一コードベースにスタイルを追加する環境
  • 型安全性を重視し、スタイルの衝突を仕組みで防ぎたい
  • UIコンポーネントを自社で構築・管理する体制がある

逆に、以下のケースでは他の選択肢が手堅いです。

  • 素早くプロトタイプを作りたい → Tailwind CSS + shadcn/ui
  • 既存のCSS資産を活かしたい → CSS Modules
  • チームにCSS-in-JSの経験がない → Tailwind CSS(ユーティリティクラスの方が学習コストが低い)

StyleXの共同開発者であるNaman Goel氏自身も、StyleXの価値は「数千のコンポーネントと数億のユーザーを持つアプリケーション」のスケールで発揮されると述べています4
裏を返せば、そのスケールに達していないプロジェクトでは、StyleXの設計上の利点よりもエコシステムの薄さの方が痛くなる可能性があります。

CSSそのものの進化も見逃せない

2023年時点では「CSS-in-JSでなければ解決できない課題」が一定数ありましたが、状況は変わりつつあります。
CSSネイティブのネスティング、コンテナクエリ、@layerによる優先順位制御が全主要ブラウザに実装され、「スコープの管理」や「スタイルの衝突防止」はCSS単体でもかなり対応できるようになりました。
「CSS-in-JSか否か」という二項対立自体が薄れてきている中で、StyleXが選ばれるかどうかは、ライブラリの技術的優位性よりもチームの規模とエコシステムの必要性で決まると言えます。

参考

https://medium.com/@huseyinsalmansoftdev/react-stylex-vite-npm-db9be1e5c5c6

https://stylexjs.com/docs/learn/

https://qiita.com/xrxoxcxox/items/8605a7a92610db56011e

https://qiita.com/_ytori/items/f12b7502cfabcadec9ac

  1. CSS at Scale With StyleX - Engineering at Meta

  2. A New Year 2026 - StyleX Blog 2

  3. Switch from Emotion to StyleX - chakra-ui/chakra-ui Discussion #8025

  4. StyleX vs. Tailwind: Meta's Take on CSS-in-JS Maintainability - Richard MacManus

16
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?